diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index 661ab7142..edb40188e 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -38,6 +38,7 @@ print_runs_verbose, read_metadata_csv_to_dict, retrieve_and_print_run_details, + share_token_access_denied_message, validate_mappings, write_metadata_dict_to_csv, ) @@ -121,6 +122,10 @@ int, typer.Option(help="Timeout for acquiring compute nodes in minutes (1-3600).", min=1, max=3600), ] +ShareTokenOption = Annotated[ + str | None, + typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), +] cli = typer.Typer(name="application", help="List and inspect applications on Aignostics Platform.") @@ -970,13 +975,15 @@ def run_describe( help="Show only run and item status summary (external ID, state, error message)", ), ] = False, + share_token: ShareTokenOption = None, ) -> None: """Describe run.""" logger.trace("Describing run with ID '{}'", run_id) try: user_info = PlatformService.get_user_info() - run = Service().application_run(run_id) + run = Service().application_run(run_id, share_token=share_token) + if format == "json": # Get run details and items, output as JSON run_details = run.details(hide_platform_queue_position=not user_info.is_internal_user) @@ -995,6 +1002,13 @@ def run_describe( else: console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") sys.exit(2) + except ForbiddenException: + msg = share_token_access_denied_message(run_id, share_token) + if format == "json": + print(json.dumps({"error": "access_denied", "message": msg}), file=sys.stderr) + else: + console.print(f"[error]Error:[/error] {msg}") + sys.exit(1) except Exception as e: logger.exception(f"Failed to retrieve and print run details for ID '{run_id}'") if format == "json": @@ -1008,6 +1022,7 @@ def run_describe( def run_dump_metadata( run_id: Annotated[str, typer.Argument(help="Id of the run to dump custom metadata for")], pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False, + share_token: ShareTokenOption = None, show_checksum: Annotated[ bool, typer.Option( @@ -1023,7 +1038,7 @@ def run_dump_metadata( logger.trace("Dumping custom metadata for run with ID '{}'", run_id) try: - run = Service().application_run(run_id).details() + run = Service().application_run(run_id, share_token=share_token).details() custom_metadata = run.custom_metadata if hasattr(run, "custom_metadata") else {} output: dict[str, Any] | Any = custom_metadata if show_checksum: @@ -1043,6 +1058,9 @@ def run_dump_metadata( logger.warning(f"Run with ID '{run_id}' not found.") console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") sys.exit(2) + except ForbiddenException: + console.print(f"[error]Error:[/error] {share_token_access_denied_message(run_id, share_token)}") + sys.exit(1) except Exception as e: logger.exception(f"Failed to dump custom metadata for run with ID '{run_id}'") console.print(f"[error]Error:[/error] Failed to dump custom metadata for run with ID '{run_id}': {e}") @@ -1054,6 +1072,7 @@ def run_dump_item_metadata( run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")], external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")], pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False, + share_token: ShareTokenOption = None, show_checksum: Annotated[ bool, typer.Option( @@ -1070,7 +1089,7 @@ def run_dump_item_metadata( logger.trace("Dumping custom metadata for item '{}' in run with ID '{}'", external_id, run_id) try: - run = Service().application_run(run_id) + run = Service().application_run(run_id, share_token=share_token) # Find the item with the matching external_id in the results item = None @@ -1106,6 +1125,9 @@ def run_dump_item_metadata( logger.warning(f"Run with ID '{run_id}' not found.") print(f"Warning: Run with ID '{run_id}' not found.", file=sys.stderr) sys.exit(2) + except ForbiddenException: + print(f"Error: {share_token_access_denied_message(run_id, share_token)}", file=sys.stderr) + sys.exit(1) except Exception as e: logger.exception(f"Failed to dump custom metadata for item '{external_id}' in run with ID '{run_id}'") print( @@ -1661,6 +1683,7 @@ def result_download( # noqa: C901, PLR0913, PLR0915 'Run uvx --with "aignostics[qupath]" aignostics qupath install' ), ] = False, + share_token: ShareTokenOption = None, ) -> None: """Download results of a run.""" logger.trace( @@ -1808,6 +1831,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 wait_for_completion=wait_for_completion, qupath_project=qupath_project, download_progress_callable=update_progress, + share_token=share_token, ) main_download_progress_ui.update(main_task, completed=100, total=100) @@ -1823,6 +1847,9 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 logger.warning(f"Bad input to download results of run with ID '{run_id}': {e}") console.print(f"[warning]Warning:[/warning] Bad input to download results of run with ID '{run_id}': {e}") sys.exit(2) + except ForbiddenException: + console.print(f"[error]Error:[/error] {share_token_access_denied_message(run_id, share_token)}") + sys.exit(1) except Exception as e: logger.exception(f"Failed to download results of run with ID '{run_id}'") console.print( diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 8914dac3a..776f2f7df 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -800,11 +800,13 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0915 logger.exception(message) raise RuntimeError(message) from e - def application_run(self, run_id: str) -> Run: + def application_run(self, run_id: str, share_token: str | None = None) -> Run: """Select a run by its ID. Args: - run_id (str): The ID of the run to find + run_id (str): The ID of the run to find. + share_token (str | None): Optional share token secret. When provided the run + is accessed via the ``share_token`` query parameter without OAuth. Returns: Run: The run that can be fetched using the .details() call. @@ -813,6 +815,8 @@ def application_run(self, run_id: str) -> Run: RuntimeError: If initializing the client fails or the run cannot be retrieved. """ try: + if share_token is not None: + return Run.for_run_id(run_id, share_token=share_token) return self._get_platform_client().run(run_id) except Exception as e: message = f"Failed to retrieve application run with ID '{run_id}': {e}" @@ -1675,6 +1679,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915 qupath_project: bool = False, download_progress_queue: Any | None = None, # noqa: ANN401 download_progress_callable: Callable | None = None, # type: ignore[type-arg] + share_token: str | None = None, ) -> Path: """Download application run results with progress tracking. @@ -1691,6 +1696,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915 of the destination directory. download_progress_queue (Queue | None): Queue for GUI progress updates. download_progress_callable (Callable | None): Callback for CLI progress updates. + share_token (str | None): Optional share token secret for unauthenticated access. Returns: Path: The directory containing downloaded results. @@ -1721,7 +1727,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915 progress = DownloadProgress() update_progress(progress, download_progress_callable, download_progress_queue) - application_run = self.application_run(run_id) + application_run = self.application_run(run_id, share_token=share_token) final_destination_directory = destination_directory try: details = application_run.details() @@ -1729,6 +1735,10 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915 message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) raise NotFoundException(message) from e + except ForbiddenException: + # Propagate 403 unchanged so the CLI can surface a share-token-specific + # "access denied" message; do not wrap it into RuntimeError below. + raise except ApiException as e: if e.status == HTTPStatus.UNPROCESSABLE_ENTITY: message = f"Run ID '{run_id}' invalid: {e!s}." diff --git a/src/aignostics/application/_utils.py b/src/aignostics/application/_utils.py index 9c52d0647..9cfd0cc3f 100644 --- a/src/aignostics/application/_utils.py +++ b/src/aignostics/application/_utils.py @@ -599,3 +599,25 @@ def get_supported_extensions_for_application(application_id: str) -> set[str]: message = f"Unsupported application {application_id}" logger.critical(message) raise RuntimeError(message) + + +def share_token_access_denied_message(run_id: str, share_token: str | None) -> str: + """Compose the operator-facing "access denied" message for a run and log a warning. + + Centralizes the wording, share-token hint, and warning log shared by the run CLI + commands that support ``--share-token``. The caller owns the output sink (console, + stderr, or JSON) and the exit code. + + Args: + run_id (str): The run access was denied for. + share_token (str | None): The share token supplied, if any. When set, a hint + that the token may be invalid, expired, or revoked is appended. + + Returns: + str: The composed message. + """ + logger.warning("Access denied for run '{}'", run_id) + message = f"Access denied for run '{run_id}'." + if share_token is not None: + message += " The share token may be invalid, expired, or revoked." + return message diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index e080a570d..3aa82351a 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -12,6 +12,7 @@ from pathlib import Path from time import sleep from typing import Any, cast +from urllib.parse import urlencode import requests from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException @@ -107,17 +108,20 @@ class Artifact(_AuthenticatedResource): ``GET /api/v1/runs/{run_id}/artifacts/{artifact_id}/file`` endpoint. """ - def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str) -> None: + def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str, share_token: str | None = None) -> None: """Initializes an Artifact instance. Args: api (_AuthenticatedApi): The configured API client. run_id (str): The ID of the parent run. artifact_id (str): The ID of the output artifact. + share_token (str | None): Optional share token secret forwarded as the + ``share_token`` query parameter on the /file endpoint request. """ super().__init__(api) self.run_id = run_id self.artifact_id = artifact_id + self._share_token = share_token def get_download_url(self) -> str: """Resolve a fresh presigned download URL for this artifact. @@ -145,6 +149,10 @@ def get_download_url(self) -> str: configuration = self._api.api_client.configuration host = configuration.host.rstrip("/") endpoint_url = f"{host}/api/v1/runs/{self.run_id}/artifacts/{self.artifact_id}/file" + if self._share_token is not None: + # Percent-encode the secret so reserved characters (& # = space) cannot + # corrupt the URL or inject extra query parameters. + endpoint_url += f"?{urlencode({'share_token': self._share_token})}" proxy = getattr(configuration, "proxy", None) ssl_ca_cert = getattr(configuration, "ssl_ca_cert", None) verify_ssl = getattr(configuration, "verify_ssl", True) @@ -192,6 +200,10 @@ def _fetch_redirect_url( RuntimeError: 3xx without a Location header, or unexpected non-3xx status. """ try: + # Always send the OAuth Bearer token: the platform requires an + # authenticated account on every request. When a share_token is present + # it is carried as a query parameter on ``endpoint_url`` and elevates + # that authenticated user's access to the shared resource. with requests.get( endpoint_url, headers={ @@ -250,30 +262,56 @@ class Run(_AuthenticatedResource): Provides operations to check status, retrieve results, and download artifacts. """ - def __init__(self, api: _AuthenticatedApi, run_id: str) -> None: + def __init__(self, api: _AuthenticatedApi, run_id: str, share_token: str | None = None) -> None: """Initializes a Run instance. Args: api (_AuthenticatedApi): The configured API client. run_id (str): The ID of the application run. + share_token (str | None): Optional share token secret. When supplied the + token is forwarded as the ``share_token`` query parameter on every API + request, elevating the authenticated user's access to the shared run. """ super().__init__(api) self.run_id = run_id + self._share_token = share_token @classmethod - def for_run_id(cls, run_id: str, cache_token: bool = True) -> "Run": - """Creates an Run instance for an existing run. + def for_run_id(cls, run_id: str, cache_token: bool = True, share_token: str | None = None) -> "Run": + """Creates a Run instance for an existing run. + + When *share_token* is provided it is forwarded as the ``share_token`` query + parameter on every API request, elevating the calling (OAuth-authenticated) + user's access to a run they would otherwise not be able to read. Args: run_id (str): The ID of the application run. - cache_token (bool): Whether to cache the API token. + cache_token (bool): Whether to use the cached OAuth token. + share_token (str | None): Optional share token secret. The caller must + still be authenticated; the token grants access to the shared run. Returns: Run: The initialized Run instance. + + Example:: + + # Authenticated access to a run you own + run = Run.for_run_id("run-abc123") + + # Share-token access to a run shared with you (still authenticated) + run = Run.for_run_id("run-abc123", share_token="shr_xxxx") + details = run.details() + for item in run.results(): + print(item.external_id) """ from aignostics.platform._client import Client # noqa: PLC0415 - return cls(Client.get_api_client(cache_token=cache_token), run_id) + # Share-token access still authenticates as the calling user: the platform + # requires an OAuth Bearer token on every request. The share_token is + # forwarded as a query parameter (see details/results/Artifact) and elevates + # the authenticated user's access to the shared resource. + api = Client.get_api_client(cache_token=cache_token) + return cls(api, run_id, share_token=share_token) def details(self, nocache: bool = False, hide_platform_queue_position: bool = False) -> RunData: """Retrieves the current status of the application run. @@ -294,9 +332,13 @@ def details(self, nocache: bool = False, hide_platform_queue_position: bool = Fa NotFoundException: If the run is not found after retries. Exception: If the API request fails. """ + share_token = self._share_token + # share_token is threaded as an explicit argument (not a closure capture) so it + # participates in the operation cache key, keeping share-token reads isolated + # from authenticated reads of the same run_id. @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def details_with_retry(run_id: str) -> RunData: + def details_with_retry(run_id: str, share_token: str | None = None) -> RunData: def _fetch() -> RunData: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -309,6 +351,7 @@ def _fetch() -> RunData: )( lambda: self._api.get_run_v1_runs_run_id_get( run_id, + share_token=share_token, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) @@ -323,7 +366,7 @@ def _fetch() -> RunData: reraise=True, )(_fetch) - run_data: RunData = details_with_retry(self.run_id, nocache=nocache) # type: ignore[call-arg] + run_data: RunData = details_with_retry(self.run_id, share_token=share_token, nocache=nocache) # type: ignore[call-arg] if hide_platform_queue_position: run_data = run_data.model_copy(deep=True) run_data.num_preceding_items_platform = None @@ -388,11 +431,14 @@ def results( # noqa: PLR0913 Raises: Exception: If the API request fails. """ + share_token = self._share_token # Create a wrapper function that applies retry logic and caching to each API call - # Caching at this level ensures having a fresh iterator on cache hits + # Caching at this level ensures having a fresh iterator on cache hits. + # share_token is an explicit argument (not a closure capture) so it participates + # in the operation cache key, isolating share-token reads from authenticated reads. @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: + def results_with_retry(run_id: str, share_token: str | None = None, **kwargs: object) -> list[ItemResultData]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), stop=stop_after_attempt(settings().run_retry_attempts), @@ -402,6 +448,7 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: )( lambda: self._api.list_run_items_v1_runs_run_id_items_get( run_id=run_id, + share_token=share_token, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, **kwargs, # pyright: ignore[reportArgumentType] @@ -420,7 +467,11 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: if custom_metadata is not None: filter_kwargs["custom_metadata"] = custom_metadata - return paginate(lambda **kwargs: results_with_retry(self.run_id, nocache=nocache, **filter_kwargs, **kwargs)) + return paginate( + lambda **kwargs: results_with_retry( + self.run_id, share_token=share_token, nocache=nocache, **filter_kwargs, **kwargs + ) + ) def download_to_folder( # noqa: C901 self, @@ -513,7 +564,7 @@ def artifact(self, artifact_id: str) -> Artifact: Returns: Artifact: A handle bound to this run and the given artifact. """ - return Artifact(self._api, self.run_id, artifact_id) + return Artifact(self._api, self.run_id, artifact_id, share_token=self._share_token) def get_artifact_download_url(self, artifact_id: str) -> str: """Resolve a fresh presigned download URL for an artifact of this run. diff --git a/tests/aignostics/application/cli_test.py b/tests/aignostics/application/cli_test.py index 46b3c36c1..efaf706e6 100644 --- a/tests/aignostics/application/cli_test.py +++ b/tests/aignostics/application/cli_test.py @@ -981,6 +981,253 @@ def test_cli_run_describe_json_includes_items(runner: CliRunner) -> None: assert item["termination_reason"] == "SUCCEEDED" +def _make_mock_run(run_id: str = "run-shared-001", custom_metadata: dict | None = None) -> MagicMock: + """Build a mock Run that returns a minimal RunReadResponse from details().""" + mock_run_data = RunReadResponse( + run_id=run_id, + application_id="test-app", + version_number="1.0.0", + state=RunState.TERMINATED, + output=RunOutput.FULL, + termination_reason=RunTerminationReason.ALL_ITEMS_PROCESSED, + error_code=None, + error_message=None, + statistics=RunItemStatistics( + item_count=0, + item_pending_count=0, + item_processing_count=0, + item_user_error_count=0, + item_system_error_count=0, + item_skipped_count=0, + item_succeeded_count=0, + ), + custom_metadata=custom_metadata, + submitted_at=datetime(2025, 1, 1, tzinfo=UTC), + submitted_by="test-user", + terminated_at=datetime(2025, 1, 1, 0, 1, tzinfo=UTC), + ) + mock_run = MagicMock() + mock_run.details.return_value = mock_run_data + mock_run.results.return_value = iter([]) + return mock_run + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_success(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token succeeds without OAuth and returns run details.""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-shared-001") + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke(cli, ["application", "run", "describe", "run-shared-001", "--share-token", "s3cr3t"]) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-shared-001", share_token="s3cr3t") # noqa: S106 + assert "run-shared-001" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_json(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token --format json returns valid JSON without OAuth.""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-shared-002") + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke( + cli, + ["application", "run", "describe", "run-shared-002", "--share-token", "s3cr3t", "--format", "json"], + ) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-shared-002", share_token="s3cr3t") # noqa: S106 + data = json.loads(result.stdout) + assert data["run_id"] == "run-shared-002" + assert "items" in data + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_not_found(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke(cli, ["application", "run", "describe", "bad-run-id", "--share-token", "s3cr3t"]) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_forbidden(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token exits 1 when token is invalid, expired, or has no access.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ForbiddenException(status=403, reason="Forbidden") + result = runner.invoke(cli, ["application", "run", "describe", "run-id", "--share-token", "bad-token"]) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_dump_metadata_with_share_token_success(runner: CliRunner, record_property: object) -> None: + """Run dump-metadata --share-token returns custom metadata JSON without OAuth.""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-001", custom_metadata={"key": "value"}) + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke(cli, ["application", "run", "dump-metadata", "run-001", "--share-token", "s3cr3t"]) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-001", share_token="s3cr3t") # noqa: S106 + assert "key" in result.output + + +@pytest.mark.integration +def test_cli_run_dump_metadata_with_share_token_forbidden(runner: CliRunner, record_property: object) -> None: + """Run dump-metadata --share-token exits 1 on forbidden.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ForbiddenException(status=403, reason="Forbidden") + result = runner.invoke(cli, ["application", "run", "dump-metadata", "run-id", "--share-token", "bad"]) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_dump_item_metadata_with_share_token_success(runner: CliRunner, record_property: object) -> None: + """Run dump-item-metadata --share-token finds an item and returns its metadata without OAuth.""" + record_property("tested-item-id", "PYSDK-145") + mock_item = MagicMock() + mock_item.external_id = "slide-001.svs" + mock_item.custom_metadata = {"slide": "meta"} + + mock_run = MagicMock() + mock_run.results.return_value = iter([mock_item]) + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke( + cli, + ["application", "run", "dump-item-metadata", "run-001", "slide-001.svs", "--share-token", "s3cr3t"], + ) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-001", share_token="s3cr3t") # noqa: S106 + assert "slide" in result.output + + +@pytest.mark.integration +def test_cli_run_dump_item_metadata_with_share_token_forbidden(runner: CliRunner, record_property: object) -> None: + """Run dump-item-metadata --share-token exits 1 on forbidden.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ForbiddenException(status=403, reason="Forbidden") + result = runner.invoke( + cli, ["application", "run", "dump-item-metadata", "run-id", "item-id", "--share-token", "bad"] + ) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_result_download_with_share_token_passes_token_to_service( + runner: CliRunner, tmp_path: Path, record_property: object +) -> None: + """Result download --share-token forwards the token to application_run_download.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_download.return_value = tmp_path + result = runner.invoke( + cli, + ["application", "run", "result", "download", "run-001", str(tmp_path), "--share-token", "s3cr3t"], + ) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + call_kwargs = mock_svc_cls.return_value.application_run_download.call_args.kwargs + assert call_kwargs.get("share_token") == "s3cr3t" + + +@pytest.mark.integration +def test_cli_result_download_with_share_token_forbidden( + runner: CliRunner, tmp_path: Path, record_property: object +) -> None: + """Result download --share-token exits 1 on forbidden.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_download.side_effect = ForbiddenException( + status=403, reason="Forbidden" + ) + result = runner.invoke( + cli, + ["application", "run", "result", "download", "run-id", str(tmp_path), "--share-token", "bad"], + ) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_describe_without_share_token_passes_none(runner: CliRunner, record_property: object) -> None: + """Run describe without --share-token calls the service with share_token=None (no leakage).""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-noshare") + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke(cli, ["application", "run", "describe", "run-noshare"]) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-noshare", share_token=None) + + +@pytest.mark.integration +def test_cli_run_dump_metadata_with_share_token_not_found(runner: CliRunner, record_property: object) -> None: + """Run dump-metadata --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke(cli, ["application", "run", "dump-metadata", "bad-run-id", "--share-token", "s3cr3t"]) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_dump_item_metadata_with_share_token_not_found(runner: CliRunner, record_property: object) -> None: + """Run dump-item-metadata --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke( + cli, ["application", "run", "dump-item-metadata", "bad-run-id", "item-id", "--share-token", "s3cr3t"] + ) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_result_download_with_share_token_not_found( + runner: CliRunner, tmp_path: Path, record_property: object +) -> None: + """Result download --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_download.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke( + cli, ["application", "run", "result", "download", "bad-run-id", str(tmp_path), "--share-token", "s3cr3t"] + ) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + @pytest.mark.e2e def test_cli_run_cancel_invalid_run_id(runner: CliRunner, record_property) -> None: """Check run cancel command fails as expected on run not found.""" diff --git a/tests/aignostics/application/service_test.py b/tests/aignostics/application/service_test.py index 4ab026072..5b5b4ec75 100644 --- a/tests/aignostics/application/service_test.py +++ b/tests/aignostics/application/service_test.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from aignx.codegen.exceptions import ApiException +from aignx.codegen.exceptions import ApiException, ForbiddenException from aignx.codegen.models import SubjectType from typer.testing import CliRunner @@ -1070,3 +1070,21 @@ def test_application_run_revoke_share_token_not_found(mock_get_client: MagicMock with pytest.raises(NotFoundException, match="No grant found"): ApplicationService().application_run_revoke_share_token("run-123", "tok-missing") + + +@pytest.mark.unit +def test_application_run_download_reraises_forbidden(tmp_path, record_property: object) -> None: + """A 403 from run.details() propagates as ForbiddenException, not wrapped into RuntimeError. + + Guards the CLI's share-token 'access denied' handler: the download path must not + swallow ForbiddenException into RuntimeError via its generic ApiException branch. + """ + record_property("tested-item-id", "PYSDK-145") + mock_run = MagicMock() + mock_run.details.side_effect = ForbiddenException(status=403, reason="Forbidden") + + with ( + patch.object(ApplicationService, "application_run", return_value=mock_run), + pytest.raises(ForbiddenException), + ): + ApplicationService().application_run_download("run-id", tmp_path, share_token="s3cr3t") # noqa: S106 diff --git a/tests/aignostics/platform/resources/runs_test.py b/tests/aignostics/platform/resources/runs_test.py index 3a0975834..867cf1af5 100644 --- a/tests/aignostics/platform/resources/runs_test.py +++ b/tests/aignostics/platform/resources/runs_test.py @@ -19,7 +19,12 @@ ) from aignostics.platform._api import _AuthenticatedApi -from aignostics.platform.resources.runs import LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, Artifact, Run, Runs +from aignostics.platform.resources.runs import ( + LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, + Artifact, + Run, + Runs, +) from aignostics.platform.resources.utils import PAGE_SIZE _PLATFORM_HOST = "https://platform-staging.aignostics.com" @@ -1402,3 +1407,118 @@ def test_update_item_custom_metadata_no_enrich_skips_sdk_metadata_builders(app_r request = call_kwargs["custom_metadata_update_request"] assert request.custom_metadata == {"key": "value"} assert "sdk" not in request.custom_metadata + + +# ───────────────────────────────────────────────────────────────────────────── +# share_token: forwarding to the API layer + OAuth-less wiring (PYSDK-145) +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_details_forwards_share_token(mock_api, record_property) -> None: + """Run.details() forwards a configured share_token to the get-run API call.""" + record_property("tested-item-id", "PYSDK-145") + mock_api.get_run_v1_runs_run_id_get.return_value = RunReadResponse.model_construct(run_id="test-run-id") + + Run(mock_api, "test-run-id", share_token="s3cr3t").details() # noqa: S106 + + assert mock_api.get_run_v1_runs_run_id_get.call_args.kwargs["share_token"] == "s3cr3t" # noqa: S105 + + +@pytest.mark.unit +def test_details_share_token_none_when_not_supplied(app_run, mock_api, record_property) -> None: + """Run.details() sends share_token=None when no share token was supplied (no leakage).""" + record_property("tested-item-id", "PYSDK-145") + mock_api.get_run_v1_runs_run_id_get.return_value = RunReadResponse.model_construct(run_id="test-run-id") + + app_run.details() + + assert mock_api.get_run_v1_runs_run_id_get.call_args.kwargs["share_token"] is None + + +@pytest.mark.unit +def test_results_forwards_share_token(mock_api, record_property) -> None: + """Run.results() forwards a configured share_token to the list-items API call on every page.""" + record_property("tested-item-id", "PYSDK-145") + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(Run(mock_api, "test-run-id", share_token="s3cr3t").results()) # noqa: S106 + + assert mock_api.list_run_items_v1_runs_run_id_items_get.call_args.kwargs["share_token"] == "s3cr3t" # noqa: S105 + + +@pytest.mark.unit +def test_results_share_token_none_when_not_supplied(app_run, mock_api, record_property) -> None: + """Run.results() sends share_token=None when no share token was supplied (no leakage).""" + record_property("tested-item-id", "PYSDK-145") + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(app_run.results()) + + assert mock_api.list_run_items_v1_runs_run_id_items_get.call_args.kwargs["share_token"] is None + + +@pytest.mark.unit +def test_artifact_get_download_url_appends_share_token(configured_api, record_property) -> None: + """Artifact.get_download_url() adds the share_token as a query parameter on the /file URL.""" + record_property("tested-item-id", "PYSDK-145") + art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID, share_token="s3cr3t") # noqa: S106 + response = _redirect_response(_PRESIGNED_URL) + + with patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get: + art.get_download_url() + + assert "share_token=s3cr3t" in mock_get.call_args.args[0] + + +@pytest.mark.unit +def test_artifact_get_download_url_percent_encodes_share_token(configured_api, record_property) -> None: + """A share_token with reserved characters is percent-encoded, not injected as extra query params.""" + record_property("tested-item-id", "PYSDK-145") + art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID, share_token="a b&x=1") # noqa: S106 + response = _redirect_response(_PRESIGNED_URL) + + with patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get: + art.get_download_url() + + url = mock_get.call_args.args[0] + assert "share_token=a+b%26x%3D1" in url + # The raw "&" must not have created a second query parameter. + assert url.count("?") == 1 + assert "&" not in url.split("?", 1)[1] + + +@pytest.mark.unit +def test_artifact_get_download_url_sends_bearer_with_share_token(configured_api, record_property) -> None: + """The /file request always sends the OAuth Bearer header alongside the share_token query param.""" + record_property("tested-item-id", "PYSDK-145") + art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID, share_token="s3cr3t") # noqa: S106 + response = _redirect_response(_PRESIGNED_URL) + + with patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get: + art.get_download_url() + + assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer t" + assert "share_token=s3cr3t" in mock_get.call_args.args[0] + + +@pytest.mark.unit +def test_for_run_id_with_share_token_uses_authenticated_client(mock_api, record_property) -> None: + """Run.for_run_id(share_token=...) uses the normal authenticated client and stores the token.""" + record_property("tested-item-id", "PYSDK-145") + with patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api) as mock_get_api_client: + run = Run.for_run_id("run-abc", share_token="s3cr3t") # noqa: S106 + + mock_get_api_client.assert_called_once_with(cache_token=True) + assert run._share_token == "s3cr3t" # noqa: S105 + + +@pytest.mark.unit +def test_for_run_id_without_share_token_uses_oauth(mock_api, record_property) -> None: + """Run.for_run_id() without a share token uses the normal cached OAuth client.""" + record_property("tested-item-id", "PYSDK-145") + with patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api) as mock_get_api_client: + run = Run.for_run_id("run-abc", cache_token=True) + + mock_get_api_client.assert_called_once_with(cache_token=True) + assert run._share_token is None