diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f2fbf32a..0645a9d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `login` and `add --connect-cloud` can start device-code authentication and return + approval details as JSON. Finish a pending login by nickname with `--finish`. + Use `--timeout` to bound the entire finish invocation, including account lookup. + Resumable login requires POSIX (Linux or macOS). Pending state is validated + before saving and uses owner-only permissions. Resumable authentication omits + sensitive provider diagnostics. Connect Cloud finish retains the original OAuth client. +- Added `rsconnect preflight` to check Python and Node.js runtime availability on + self-hosted Posit Connect and Snowpark Container Services (SPCS), from POSIX systems. For first + publishes, it considers server installations marked publishable. Python + preflight can add a missing `.python-version` for new content with no + constraint; Node.js preflight checks `package.json`'s `engines.node` range and + server runtime availability. + Preflight accepts Python file targets as well as directories. Its JSON reports + distinguish unresolved checks from operational errors. Runtime incompatibility + exits with status 3; operational errors exit with status 1. + Unsafe or oversized deployment records return actionable uncertainty. - `write-manifest` now writes `requirements.txt` with the same bytes that it uses for the manifest checksum. On Windows, the file had CRLF line endings and the checksum did not match. An existing requirements file with CRLF line endings also has the correct diff --git a/docs/commands/add.md b/docs/commands/add.md index deaba81f..6359d985 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -1,3 +1,64 @@ +# `rsconnect add` + +Use `add --connect-cloud` to authenticate with Posit Connect Cloud. The command normally waits for browser approval. If the shell must return before approval, start device authentication and finish it separately. + +Both `rsconnect add` and its `rsconnect server add` alias support these options. + +## Start Connect Cloud authentication + +Pass the account and nickname, then add `--no-wait`: + +```{.bash filename="Terminal"} +rsconnect add --connect-cloud --account team --name cloud --no-wait +``` + +The command exits with JSON that includes the approval URL, the user code, and the duration until expiration. Show the user `verification_uri` and `user_code` so they can approve the request. The JSON does not include the secret device code or any token. + +If a valid pending login already exists for the same account and nickname, the command reuses it. +Starting a login for a different target with that nickname fails. +The command stores pending logins under its configuration directory. Files use owner-only permissions on POSIX. +Resumable device login requires a POSIX system such as Linux or macOS. Use a private configuration directory. +Existing blocking login remains available on Windows. + +Pending state contains device codes and token checkpoints in plaintext, even when final credentials use a keyring. +Owner-only permissions restrict ordinary access to your operating-system account; processes running as that account and backups can still read it. +Finish pending logins promptly. Abandoned state has no background cleanup, and deleting local state does not revoke an issued token. +Use HTTPS with certificate verification and keep the configuration directory private and outside your application directory. + +Finish the login after the user approves: + +```{.bash filename="Terminal"} +rsconnect add --connect-cloud --name cloud --finish --timeout 120 +``` + +The timeout bounds the entire finish invocation, including account lookup. +Slowly streamed response headers and bodies use the remaining timeout budget. An operating-system DNS lookup can still outlast it. +Finish reuses the OAuth client selected when the login started. +The command prints JSON with `status` set to `pending` or `done`. Finish requires `--connect-cloud` and `--name`. +It accepts `--connect-cloud`, `--name`, `--finish`, optional `--timeout`, and optional verbosity flags. + +Do not pass account, server, TLS, identity, client, or default options. `--timeout` accepts any positive integer and defaults to 120 seconds. Use it only with `--finish`. + +A pending result exits successfully so callers can parse the JSON. A transient pending result remains available for another finish attempt. +Denied, expired, or rejected authorization requests exit with status 1 and remove the pending request. +A completed lookup that cannot find the requested account also removes the request, so you can start again with a corrected account name. +Concurrent login operations for the same nickname serialize their state updates. +If finish exhausts its timeout waiting for another operation, it reports `pending` with `server: null`. + +Start reports contain `status`, `name`, `server`, `verification_uri`, `user_code`, and `expires_in`. +Pending finish reports contain `status`, `name`, and `server`. Completed finish reports also contain `account`. +Errors exit with status 1 and print diagnostics to stderr. Invalid command syntax exits with status 2. +Use `-v` or `-vv` for diagnostics on stderr; stdout remains reserved for the JSON result. + +The `rsconnect server add` alias accepts the same commands: + +```{.bash filename="Terminal"} +rsconnect server add --connect-cloud --account team --name cloud --no-wait +rsconnect server add --connect-cloud --name cloud --finish --timeout 120 +``` + +Without `--no-wait` or `--finish`, `add` keeps its existing blocking behavior and options. + ::: mkdocs-click :module: rsconnect.main :command: add diff --git a/docs/commands/login.md b/docs/commands/login.md index 6fe26f29..00eacfe5 100644 --- a/docs/commands/login.md +++ b/docs/commands/login.md @@ -1,3 +1,69 @@ +# `rsconnect login` + +Use `login` to authenticate with Posit Connect through OAuth. The command normally opens a browser and waits for approval. If the shell must return before approval, start device authentication and finish it in separate commands. + +The `--identity-token` and `--identity-token-file` options accept OpenID Connect (OIDC) identity tokens for token exchange. + +## Start device authentication + +Pass the server base URL, a nickname, and `--no-wait`. This option selects device authentication without requiring `--use-device-code`: + +```{.bash filename="Terminal"} +rsconnect login https://connect.example.com --name myserver --no-wait +``` + +The command exits with JSON that includes the approval URL, the user code, and the duration until expiration: + +```json +{ + "status": "pending", + "name": "myserver", + "server": "https://connect.example.com", + "verification_uri": "https://connect.example.com/activate", + "user_code": "ABCD-EFGH", + "expires_in": 900 +} +``` + +Show the user `verification_uri` and `user_code` so they can approve the request. The JSON does not include the secret device code or any token. + +If a valid pending login already exists for the same server and nickname, `login --no-wait` reuses it. +Starting a login for a different target with that nickname fails. +The command stores pending logins under its configuration directory. Files use owner-only permissions on POSIX. +Resumable device login requires a POSIX system such as Linux or macOS. Use a private configuration directory. +Existing blocking login remains available on Windows. + +Pending state contains device codes and token checkpoints in plaintext, even when final credentials use a keyring. +Owner-only permissions restrict ordinary access to your operating-system account; processes running as that account and backups can still read it. +Finish pending logins promptly. Abandoned state has no background cleanup, and deleting local state does not revoke an issued token. +Use HTTPS with certificate verification and keep the configuration directory private and outside your application directory. + +## Finish device authentication + +After the user approves the request, finish the login by naming the saved nickname: + +```{.bash filename="Terminal"} +rsconnect login --name myserver --finish --timeout 120 +``` + +The timeout bounds the entire finish invocation. The command prints JSON with `status` set to `pending` or `done`. +Slowly streamed response headers and bodies use the remaining timeout budget. An operating-system DNS lookup can still outlast it. +`--timeout` accepts any positive integer and defaults to 120 seconds. Use it only with `--finish`. + +Finish selects the pending login by `--name`, so omit the `SERVER` argument. It accepts `--name`, `--finish`, optional `--timeout`, and optional verbosity flags. +Do not pass the server URL or any other start option. + +A pending result exits successfully so callers can parse the JSON. A transient pending result remains available for another finish attempt. +Denied, expired, or rejected authorization requests exit with status 1 and remove the pending request. +Concurrent login operations for the same nickname serialize their state updates. +If finish exhausts its timeout waiting for another operation, it reports `pending` with `server: null`. + +Start reports contain `status`, `name`, `server`, `verification_uri`, `user_code`, and `expires_in`. +Finish reports contain `status`, `name`, and `server`. Errors exit with status 1 and print diagnostics to stderr. +Invalid command syntax exits with status 2. Use `-v` or `-vv` for diagnostics on stderr; stdout remains reserved for the JSON result. + +Without `--no-wait` or `--finish`, `login` keeps its existing blocking behavior and options. + ::: mkdocs-click :module: rsconnect.main :command: login diff --git a/docs/commands/preflight.md b/docs/commands/preflight.md new file mode 100644 index 00000000..ae712bd6 --- /dev/null +++ b/docs/commands/preflight.md @@ -0,0 +1,96 @@ +# `rsconnect preflight` + +Use `preflight` to compare a project's runtime constraint with versions reported by a +self-hosted Posit Connect or Snowpark Container Services (SPCS) server. It supports +OAuth credentials and does not support Posit Connect Cloud. Preflight checks runtime +availability only. It does not assess application or dependency compatibility. +Preflight requires a POSIX system such as Linux or macOS; existing deployment commands remain available on Windows. + +```{.bash filename="Terminal"} +rsconnect preflight --name myserver ./project +``` + +The `--runtime` option accepts `python` or `nodejs` and defaults to `python`. Select +Node.js with `--runtime nodejs`: + +```{.bash filename="Terminal"} +rsconnect preflight -n myserver ./node-app --runtime nodejs +``` + +For Node.js, preflight compares `package.json`'s `engines.node` constraint with server +Node.js installations using npm semver. Node.js must be enabled and allowed by the +server license. For first publishes, an installation must also be marked publishable. +If a legacy server omits required runtime or publishability flags, the result can be +`unknown`. + +Use `--node` with a path to select the local Node.js executable. It is valid only with +`--runtime nodejs`; passing it with Python is an error. Node.js preflight does not +support `--fix` and does not modify `package.json`, `package-lock.json`, or other +project files. + +The command prints a JSON report with `status` set to `ok`, `incompatible`, or +`unknown`, along with runtime diagnostics, `warnings`, and `actions`. Operational errors return an `error` report. +For Python, the +report includes: + +- `status`, `runtime`, `server`, `warnings`, and `actions`: check results, runtime name, and suggested next steps +- `publishable_python_versions`: Python versions from installations the server marks publishable for first publishes +- `local_python`: the local Python version +- `local_python_publishable`: whether the server marks the local Python minor version publishable, or `null` if unknown +- `recommended_python`: a suggested Python minor version for unconstrained first publication, or `null` +- `python_requires`: the project’s declared Python requirement, if present +- `existing_content`: an object with `exists`, `app_id`, `installed_python_version`, `server_python_versions`, and `python_compatibility` +- `quarto_available`: whether the local Quarto command is available +- `changed_files`: files changed by `--fix` + +For Node.js, the report includes: + +- `status`, `runtime`, `server`, `warnings`, and `actions`: check results, runtime name, and suggested next steps +- `local_node`: the selected local Node.js version +- `node_requires`: the `package.json` `engines.node` range +- `server_node_versions`: the server-reported installed Node.js versions +- `publishable_node_versions`: versions marked publishable for first publishes +- `nodejs_enabled` and `nodejs_status`: server Node.js availability and status +- `existing_content`: an object with `exists`, `app_id`, `node_version`, and `compatibility` +- `changed_files`: empty because Node.js preflight does not change project files + +The server's enabled, license, and publishable flags inform the status. Treat `unknown` +as unresolved and inspect `warnings` and `actions`; do not treat it as compatible. An +`incompatible` result exits with status 3. An `ok` or `unknown` result exits with status 0. +Operational errors exit with status 1. Their JSON contains `status: error`, `runtime`, `error`, `changed_files`, `warnings`, and `actions`. +Invalid command syntax exits with status 2 and prints diagnostics to stderr. +Use `-v` or `-vv` for diagnostics on stderr; stdout remains reserved for the JSON result. + +Use `--new` to check a new content item when the project has a deployment record. Use `--app-id` to check a content item by its identifier (ID) or globally unique identifier (GUID). Do not combine these options. + +```{.bash filename="Terminal"} +rsconnect preflight --name myserver ./project --new +rsconnect preflight --name myserver ./project --app-id 00000000-0000-0000-0000-000000000000 +``` + +For content deployed from a notebook, manifest, bundle, or Quarto file, pass that file to select its deployment record: + +```{.bash filename="Terminal"} +rsconnect preflight --name myserver ./project/report.ipynb +``` + +Preflight reads project metadata and local prerequisites from the file's parent directory. +An unreadable content item or ambiguous deployment record returns `unknown`; inspect its actions before deploying. +If a directory contains file deployment history, pass the exact deployed file or `--app-id` to resolve the target. +An unresolved target prevents `--fix` from creating a Python pin. +Symlinked, non-regular, or oversized deployment records also return `unknown`. +Inspect the suggested actions and repair the record, or pass `--app-id` to select the existing content directly. + +## Add a Python version pin + +For new content with no Python constraint, add `--fix` to create a missing `.python-version` file: + +```{.bash filename="Terminal"} +rsconnect preflight --name myserver ./project --fix +``` + +The command chooses an installation the server marks publishable for first publishes. If its Python minor version differs from the local Python version, the report includes a compatibility warning. `--fix` never overwrites an existing `.python-version` file or adds a pin when the project declares a Python constraint. The report lists any file it creates in `changed_files`. + +::: mkdocs-click + :module: rsconnect.main + :command: preflight diff --git a/mkdocs.yml b/mkdocs.yml index 8431e4ec..ddfc2981 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - integration: commands/integration.md - login: commands/login.md - logout: commands/logout.md + - preflight: commands/preflight.md - quickstart: commands/quickstart.md - server: - add: commands/add.md diff --git a/rsconnect/api.py b/rsconnect/api.py index 06f61fed..19c491d1 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -259,6 +259,7 @@ def __init__( url: Optional[str] = None, server_name: Optional[str] = None, account_id: Optional[str] = None, + oauth_client_id: Optional[str] = None, ): # Accepts the bare "connect.posit.cloud" a user types for --server, the # same way ShinyappsServer accepts "shinyapps.io". @@ -267,6 +268,9 @@ def __init__( # The account's id, when it is known without asking the server. Only valid # for account_name: the two must be set together. self.account_id = account_id + # An in-progress device login keeps the public OAuth client it started + # with, even if CONNECT_CLOUD_OAUTH_CLIENT_ID changes before it finishes. + self.oauth_client_id = oauth_client_id self.access_token = access_token self.refresh_token = refresh_token # Retained so a new access token can be minted non-interactively when @@ -668,6 +672,11 @@ def python_settings(self) -> PyInfo: response = self._server.handle_bad_response(response) return response + def nodejs_settings(self) -> dict[str, Any]: + """Return Node.js availability and publishability information.""" + response = self.get("v1/server_settings/nodejs") + return cast(typing.Dict[str, Any], self._server.handle_bad_response(response)) + def app_get(self, app_id: str) -> ContentItemV0: response = cast(Union[ContentItemV0, HTTPResponse], self.get(f"applications/{app_id}")) response = self._server.handle_bad_response(response) @@ -1375,6 +1384,7 @@ def __init__( polling: bool = True, quarto_inputs: Optional[List[str]] = None, infer_target: Optional[bool] = None, + app_store: Optional[AppStore] = None, ) -> None: self.remote_server: TargetableServer self.client: RSConnectClient | PositClient | ConnectCloudClient @@ -1398,7 +1408,9 @@ def __init__( # None outside the Quarto deploy commands. self.quarto_inputs = quarto_inputs self.app_mode: AppMode | None = None - self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path)) + self.app_store: AppStore = ( + app_store if app_store is not None else AppStore(fake_module_file_from_directory(self.path)) + ) self.app_store_version: int | None = None self.api_key_is_required: bool | None = None # `deploy manifest` / `deploy bundle` pre-resolve `title` to a @@ -3389,6 +3401,26 @@ def _can_refresh_token(self) -> bool: server = self._server return bool(server.refresh_token or (server.client_id and server.client_secret)) + def _refresh_user_token(self) -> dict[str, Any]: + request_options: dict[str, Any] = {} + if self.request_deadline is not None: + remaining = self.request_deadline - time.monotonic() + if remaining <= 0: + raise RSConnectException("Device login finish deadline exceeded.") + request_options["request_timeout"] = min(self.request_timeout or remaining, remaining) + request_options["request_deadline"] = self.request_deadline + if self._server.oauth_client_id is not None: + request_options["client_id_override"] = self._server.oauth_client_id + if self._suppress_oauth_response_logging: + request_options["suppress_response_logging"] = True + return connect_cloud.refresh(cast(str, self._server.refresh_token), self._server.environment, **request_options) + + def _warn_refresh_failure(self, exception: Exception) -> None: + if self._suppress_oauth_response_logging: + logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exception).__name__) + else: + logger.warning("Posit Connect Cloud token refresh failed: %s" % exception) + def _attempt_token_refresh(self) -> bool: """Mint a new access token and apply it to this client. @@ -3410,13 +3442,13 @@ def _attempt_token_refresh(self) -> bool: server.client_id, server.client_secret, server.environment ) elif server.refresh_token: - tokens = connect_cloud.refresh(server.refresh_token, server.environment) + tokens = self._refresh_user_token() else: return False except InvalidClientError as exc: if not service_account: # This CLI's own OAuth client, not the user's credential. - logger.warning("Posit Connect Cloud token refresh failed: %s" % exc) + self._warn_refresh_failure(exc) return False raise RSConnectException( "The Posit Connect Cloud service account credential was rejected — it has been revoked or " @@ -3425,7 +3457,7 @@ def _attempt_token_refresh(self) -> bool: ) from exc except InvalidGrantError as exc: if service_account: - logger.warning("Posit Connect Cloud token refresh failed: %s" % exc) + self._warn_refresh_failure(exc) return False self._persist_tokens(None, None) raise RSConnectException( @@ -3433,7 +3465,7 @@ def _attempt_token_refresh(self) -> bool: "Authenticate again with `%s`." % self._add_command() ) from exc except RSConnectException as exc: - logger.warning("Posit Connect Cloud token refresh failed: %s" % exc) + self._warn_refresh_failure(exc) return False access_token = tokens.get("access_token") diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index eb7dba1f..dd58dafd 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -236,14 +236,30 @@ def login_client_credentials( ) -def refresh(refresh_token: str, environment: Optional[str] = None) -> dict[str, Any]: +def refresh( + refresh_token: str, + environment: Optional[str] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + client_id_override: Optional[str] = None, + *, + suppress_response_logging: bool = False, +) -> dict[str, Any]: """Mint a new access token from a refresh token.""" env = environment or environment_name() + request_options: dict[str, Any] = {} + if request_timeout is not None: + request_options["request_timeout"] = request_timeout + if request_deadline is not None: + request_options["request_deadline"] = request_deadline + if suppress_response_logging: + request_options["suppress_response_logging"] = True return refresh_access_token( metadata=urls(env).oauth_metadata(), - client_id=client_id(env), + client_id=client_id_override if client_id_override is not None else client_id(env), refresh_token=refresh_token, scope=SCOPE, + **request_options, ) diff --git a/rsconnect/device_login.py b/rsconnect/device_login.py new file mode 100644 index 00000000..bdb732be --- /dev/null +++ b/rsconnect/device_login.py @@ -0,0 +1,1209 @@ +"""Resumable OAuth device login for Posit Connect and Connect Cloud.""" + +from __future__ import annotations + +import base64 +import binascii +import errno +import hashlib +import json +import math +import os +import stat +import tempfile +import time +from contextlib import contextmanager +from typing import Any, Dict, Generator, Mapping, Optional, cast +from urllib.parse import urlsplit, urlunsplit + +from . import api, connect_cloud +from .exception import ConnectCloudAccountNotFoundError, RSConnectException +from .metadata import ServerDataDict, ServerStore, config_dirname +from .oauth import ( + InvalidClientError, + InvalidGrantError, + _oauth_response_data, + _post_oauth_form_request, + _unwrap_json_response, + discover_oauth_metadata, + keyring_store_token, + register_client, +) +from .validation import require_posix + +_START_TIMEOUT = 120 +_STATE_LOCK_TIMEOUT = 10 +_POLL_INTERVAL_STEP = 5 +_LOCK_POLL_INTERVAL = 0.05 + + +class _Denied(RSConnectException): + def __init__(self) -> None: + super().__init__("Device authorization was denied.") + + +class _Expired(RSConnectException): + def __init__(self) -> None: + super().__init__("Device authorization expired. Start a new login.") + + +class _Pending(Exception): + pass + + +class _SlowDown(Exception): + pass + + +class _FinishDeadline(Exception): + pass + + +class _LockBusy(RSConnectException): + pass + + +_DEVICE_ERRORS: dict[str, type[Exception]] = { + "authorization_pending": _Pending, + "slow_down": _SlowDown, + "access_denied": _Denied, + "expired_token": _Expired, + "invalid_client": InvalidClientError, + "invalid_grant": InvalidGrantError, +} +_STATE_REQUIRED = ( + "version", + "kind", + "name", + "server", + "client_id", + "device_endpoint", + "token_endpoint", + "device_code", + "user_code", + "verification_uri", + "expires_at", + "interval", + "last_poll_at", + "scope", + "set_default", + "insecure", + "ca_data", + "ca_data_b64", + "account", + "tokens", +) + + +def _identifier(value: str, label: str) -> str: + if not isinstance(value, str) or not value: + raise RSConnectException("%s must be a non-empty string." % label) + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise RSConnectException("%s must not contain control characters." % label) + return value + + +def _parse_http_url( + value: str, + label: str, + allow_query: bool = False, + allow_fragment: bool = False, +) -> tuple[Any, str]: + _validate_url_text(value, label) + try: + parts = urlsplit(value) + authority = _url_authority(parts, label, allow_query, allow_fragment) + except ValueError as exc: + raise RSConnectException("%s must be a valid HTTP or HTTPS URL." % label) from exc + return parts, urlunsplit((parts.scheme.lower(), authority, parts.path, parts.query, parts.fragment)) + + +def _validate_url_text(value: str, label: str) -> None: + if not isinstance(value, str) or not value: + raise RSConnectException("%s must be a valid HTTP or HTTPS URL." % label) + if any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value): + raise RSConnectException("%s must not contain whitespace or control characters." % label) + + +def _url_authority(parts: Any, label: str, allow_query: bool, allow_fragment: bool) -> str: + scheme = parts.scheme.lower() + if scheme != "http" and scheme != "https": + raise RSConnectException("%s must use HTTP or HTTPS." % label) + _validate_url_policy(parts, label, allow_query, allow_fragment) + host = parts.hostname + if not host: + raise RSConnectException("%s must include a host." % label) + port = parts.port + if port == 0: + raise RSConnectException("%s contains an invalid port." % label) + return _format_authority(host, port, scheme) + + +def _validate_url_policy(parts: Any, label: str, allow_query: bool, allow_fragment: bool) -> None: + if parts.username is not None or parts.password is not None: + raise RSConnectException("%s must not contain user information." % label) + if parts.query and not allow_query: + raise RSConnectException("%s must not contain a query or fragment." % label) + if parts.fragment and not allow_fragment: + raise RSConnectException("%s must not contain a query or fragment." % label) + + +def _format_authority(host: str, port: Optional[int], scheme: str) -> str: + host = host.lower() + authority = "[%s]" % host if ":" in host else host + if port is not None and port != (443 if scheme == "https" else 80): + authority += ":%d" % port + return authority + + +def _normalize_server_url(url: str) -> str: + parts, normalized = _parse_http_url(url, "Server URL", allow_query=True, allow_fragment=True) + authority = urlsplit(normalized).netloc + path = parts.path.rstrip("/") + if path.endswith("/__api__"): + path = path[: -len("/__api__")] + return urlunsplit((parts.scheme.lower(), authority, path, "", "")) + + +def _cloud_server_url(url: Optional[str]) -> str: + if url is not None and not connect_cloud.is_connect_cloud_url(_identifier(url, "Connect Cloud URL")): + raise RSConnectException("Connect Cloud URL must identify a supported environment.") + return _normalize_server_url(connect_cloud.resolve_url(url)) + + +def _normalize_endpoint(url: str, label: str, target_https: bool, allow_query: bool = True) -> str: + parts, normalized = _parse_http_url(url, label, allow_query=allow_query) + if target_https and parts.scheme.lower() != "https": + raise RSConnectException("%s must use HTTPS when the server URL uses HTTPS." % label) + return normalized + + +def _verification_uri(value: str, target_https: bool) -> str: + parts, normalized = _parse_http_url(value, "Verification URI", True, True) + if target_https and parts.scheme.lower() != "https": + raise RSConnectException("Verification URI must use HTTPS when the server URL uses HTTPS.") + return normalized + + +def _endpoint_location(endpoint: str) -> tuple[str, str]: + _, normalized = _parse_http_url(endpoint, "OAuth endpoint", allow_query=True) + parsed = urlsplit(normalized) + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + return "%s://%s" % (parsed.scheme, parsed.netloc), path + + +def _metadata(metadata: dict[str, Any], server: str) -> dict[str, Any]: + if not isinstance(metadata, dict): + raise RSConnectException("OAuth discovery returned invalid metadata.") + checked = dict(metadata) + target_https = urlsplit(server).scheme == "https" + for key in ("device_authorization_endpoint", "token_endpoint"): + endpoint = metadata.get(key) + if not isinstance(endpoint, str) or not endpoint: + raise RSConnectException("OAuth metadata is missing %s." % key.replace("_", " ")) + checked[key] = _normalize_endpoint(endpoint, key, target_https) + registration = metadata.get("registration_endpoint") + if registration: + if not isinstance(registration, str): + raise RSConnectException("OAuth registration endpoint is invalid.") + checked["registration_endpoint"] = _normalize_endpoint( + registration, "registration_endpoint", target_https, allow_query=False + ) + return checked + + +def _store() -> ServerStore: + return ServerStore(base_dir=config_dirname()) + + +def _state_path(kind: str, name: str) -> str: + digest = _state_digest(kind, name) + return os.path.join(config_dirname(), "device-login-%s-%s.json" % (kind, digest)) + + +def _state_digest(kind: str, name: str) -> str: + return hashlib.sha256((kind + "\0" + name).encode("utf-8")).hexdigest() + + +def _temporary_state_prefix(kind: str, name: str) -> str: + return ".device-login-%s-" % _state_digest(kind, name) + + +def _remove_temporary_state_files(kind: str, name: str) -> None: + directory = os.path.dirname(_state_path(kind, name)) + try: + names = os.listdir(directory) + except FileNotFoundError: + return + except OSError as exc: + raise RSConnectException("Could not clean up pending device login state.") from exc + + prefix = _temporary_state_prefix(kind, name) + for filename in names: + if filename.startswith(prefix): + try: + os.unlink(os.path.join(directory, filename)) + except FileNotFoundError: + pass + except OSError as exc: + raise RSConnectException("Could not clean up pending device login state.") from exc + + +def _same_open_file(path: str, descriptor: int) -> bool: + try: + path_info = os.stat(path, follow_symlinks=False) + open_info = os.fstat(descriptor) + except OSError: + return False + return ( + stat.S_ISREG(path_info.st_mode) + and path_info.st_dev == open_info.st_dev + and path_info.st_ino == open_info.st_ino + ) + + +def _validate_open_state_file(path: str, descriptor: int, purpose: str) -> None: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or not _same_open_file(path, descriptor): + raise RSConnectException("Pending device login %s is not a regular file." % purpose) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077: + raise RSConnectException("Pending device login %s is not owner-only." % purpose) + + +def _open_state_file(path: str, flags: int, mode: int, purpose: str) -> int: + if os.path.islink(path): + raise RSConnectException("Could not safely %s pending device login state." % purpose) + descriptor = os.open(path, flags, mode) + try: + _validate_open_state_file(path, descriptor, purpose) + return descriptor + except BaseException: + os.close(descriptor) + raise + + +def _open_state_lock(kind: str, name: str) -> int: + path = _state_path(kind, name) + ".lock" + directory = os.path.dirname(path) + descriptor: Optional[int] = None + try: + os.makedirs(directory, mode=0o700, exist_ok=True) + flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) + descriptor = _open_state_file(path, flags, 0o600, "lock") + return descriptor + except OSError as exc: + if descriptor is not None: + os.close(descriptor) + raise RSConnectException("Could not safely lock pending device login state.") from exc + + +def _try_state_lock(descriptor: int) -> None: + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _acquire_state_lock(descriptor: int, name: str, deadline: float) -> None: + busy_errors = (errno.EACCES, errno.EAGAIN, getattr(errno, "EDEADLK", errno.EAGAIN)) + while True: + try: + _try_state_lock(descriptor) + return + except OSError as exc: + if exc.errno not in busy_errors: + raise RSConnectException("Could not lock pending device login state.") from exc + remaining = deadline - time.monotonic() + if remaining <= 0: + raise _LockBusy('Another device login operation is in progress for nickname "%s".' % name) + time.sleep(min(_LOCK_POLL_INTERVAL, remaining)) + + +@contextmanager +def _state_lock(kind: str, name: str, deadline: float) -> Generator[None, None, None]: + descriptor = _open_state_lock(kind, name) + try: + _acquire_state_lock(descriptor, name, deadline) + yield + finally: + os.close(descriptor) + + +def _write_state(state: dict[str, Any]) -> None: + target = _state_path(state["kind"], state["name"]) + directory = os.path.dirname(target) + os.makedirs(directory, mode=0o700, exist_ok=True) + try: + descriptor, temporary = tempfile.mkstemp( + prefix=_temporary_state_prefix(state["kind"], state["name"]), dir=directory + ) + except OSError as exc: + raise RSConnectException("Could not safely write pending device login state.") from exc + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + _validate_open_state_file(temporary, stream.fileno(), "write") + json.dump(state, stream, separators=(",", ":"), sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + except BaseException as exc: + try: + os.unlink(temporary) + except OSError: + pass + if isinstance(exc, OSError): + raise RSConnectException("Could not safely write pending device login state.") from exc + raise + + +def _valid_number(value: Any) -> bool: + if not isinstance(value, (int, float)) or isinstance(value, bool): + return False + try: + return math.isfinite(value) + except OverflowError: + return False + + +def _positive_finite_duration(value: Any) -> bool: + return type(value) is int and value >= 1 and _valid_number(value) + + +def _validate_tokens(tokens: Any) -> None: + if tokens is None: + return + if not isinstance(tokens, dict): + raise RSConnectException("Pending device login state is invalid.") + token_data = cast(Mapping[str, Any], tokens) + if not isinstance(token_data.get("access_token"), str) or not token_data["access_token"]: + raise RSConnectException("Pending device login state is invalid.") + if token_data.get("refresh_token") is not None and not isinstance(token_data["refresh_token"], str): + raise RSConnectException("Pending device login state is invalid.") + expiry = token_data.get("expires_at") + if expiry is not None and not _valid_number(expiry): + raise RSConnectException("Pending device login state is invalid.") + + +def _validate_state_target(state: dict[str, Any], kind: str) -> None: + server = _normalize_server_url(state["server"]) + if server != state["server"]: + raise RSConnectException("Pending device login state is invalid.") + if kind == "cloud": + _validate_cloud_target(state, server) + elif state["account"] is not None or state["scope"] is not None: + raise RSConnectException("Pending device login state is invalid.") + + +def _validate_cloud_target(state: dict[str, Any], server: str) -> None: + if not isinstance(state["account"], str) or not state["account"]: + raise RSConnectException("Pending device login state is invalid.") + cloud_target = server == _cloud_server_url(server) and state["scope"] == connect_cloud.SCOPE + no_custom_tls = not state["insecure"] and state["ca_data"] is None and state["ca_data_b64"] is None + if not cloud_target or not no_custom_tls: + raise RSConnectException("Pending device login state is invalid.") + + +def _validate_state_identity(kind: str, name: str, raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise RSConnectException("Pending device login state is invalid.") + for key in _STATE_REQUIRED: + if key not in raw: + raise RSConnectException("Pending device login state is invalid.") + state = cast(Dict[str, Any], raw) + if type(state["version"]) is not int or state["version"] != 1 or state["kind"] != kind or state["name"] != name: + raise RSConnectException("Pending device login state does not match the requested login.") + _identifier(state["name"], "Nickname") + _identifier(state["client_id"], "OAuth client ID") + if not isinstance(state["set_default"], bool) or not isinstance(state["insecure"], bool): + raise RSConnectException("Pending device login state is invalid.") + return state + + +def _validate_state_tls(raw: dict[str, Any]) -> None: + if raw["ca_data"] is not None and not isinstance(raw["ca_data"], str): + raise RSConnectException("Pending device login state is invalid.") + if raw["ca_data_b64"] is not None: + if not isinstance(raw["ca_data_b64"], str) or raw["ca_data"] is not None: + raise RSConnectException("Pending device login state is invalid.") + try: + base64.b64decode(raw["ca_data_b64"].encode("ascii"), validate=True) + except (UnicodeEncodeError, binascii.Error) as exc: + raise RSConnectException("Pending device login state is invalid.") from exc + + +def _validate_state_protocol(raw: dict[str, Any]) -> None: + server = cast(str, raw["server"]) + target_https = urlsplit(server).scheme == "https" + _validate_state_endpoints(raw, target_https) + _validate_state_codes(raw) + + +def _validate_state_endpoints(raw: dict[str, Any], target_https: bool) -> None: + for key in ("device_endpoint", "token_endpoint"): + endpoint = raw[key] + if not isinstance(endpoint, str) or _normalize_endpoint(endpoint, key, target_https) != endpoint: + raise RSConnectException("Pending device login state is invalid.") + verification = _verification_uri(raw["verification_uri"], target_https) + if verification != raw["verification_uri"]: + raise RSConnectException("Pending device login state is invalid.") + + +def _validate_state_codes(raw: dict[str, Any]) -> None: + if not isinstance(raw["device_code"], str) or not raw["device_code"]: + raise RSConnectException("Pending device login state is invalid.") + if not isinstance(raw["user_code"], str) or not raw["user_code"]: + raise RSConnectException("Pending device login state is invalid.") + + +def _validate_state_polling(raw: dict[str, Any]) -> None: + if not _valid_number(raw["expires_at"]) or not _valid_number(raw["last_poll_at"]): + raise RSConnectException("Pending device login state is invalid.") + if not _positive_finite_duration(raw["interval"]): + raise RSConnectException("Pending device login state is invalid.") + _validate_tokens(raw["tokens"]) + + +def _validate_state(kind: str, name: str, raw: Any) -> dict[str, Any]: + state = _validate_state_identity(kind, name, raw) + _validate_state_tls(state) + _validate_state_target(state, kind) + _validate_state_protocol(state) + _validate_state_polling(state) + return state + + +def _read_state(kind: str, name: str) -> Optional[dict[str, Any]]: + path = _state_path(kind, name) + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + try: + descriptor = _open_state_file(path, flags, 0, "read") + except FileNotFoundError: + return None + except OSError as exc: + raise RSConnectException("Could not safely read pending device login state.") from exc + try: + raw = _read_state_json(descriptor) + finally: + try: + os.close(descriptor) + except OSError: + pass + return _validate_state(kind, name, raw) + + +def _read_state_json(descriptor: int) -> Any: + try: + with os.fdopen(descriptor, "r", encoding="utf-8", closefd=False) as stream: + return json.load(stream) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RSConnectException("Pending device login state is invalid.") from exc + + +def _remove_state(kind: str, name: str) -> None: + try: + os.unlink(_state_path(kind, name)) + except FileNotFoundError: + pass + + +def _ca_values(ca_data: Optional[str | bytes]) -> tuple[Optional[str], Optional[str]]: + if ca_data is None: + return None, None + if isinstance(ca_data, str): + return ca_data, None + if isinstance(ca_data, bytes): + return None, base64.b64encode(ca_data).decode("ascii") + raise RSConnectException("CA data must be text or bytes.") + + +def _state_ca(state: dict[str, Any]) -> Optional[str | bytes]: + if state["ca_data_b64"] is not None: + return base64.b64decode(state["ca_data_b64"].encode("ascii"), validate=True) + return state["ca_data"] + + +def _same_server(value: Any, server: str) -> bool: + if not isinstance(value, str): + return False + try: + return _normalize_server_url(value) == server + except RSConnectException: + return False + + +def _assert_nickname_target( + store: ServerStore, kind: str, name: str, server: str, account: Optional[str] = None +) -> None: + entry = store.get_by_name(name) + if entry is None: + return + matches = _same_server(entry.get("url"), server) and _saved_server_kind(entry) == kind + if kind == "cloud": + matches = matches and entry.get("connect_cloud_account_name") == account + if not matches: + raise RSConnectException('The nickname, "%s", is already saved for a different target.' % name) + + +def _saved_server_kind(entry: Mapping[str, Any]) -> str: + if entry.get("connect_cloud_account_name"): + return "cloud" + if any(entry.get(key) for key in ("account_name", "token", "secret", "snowflake_connection_name")): + return "other" + return "connect" + + +def _saved_connect_entry(store: ServerStore, name: str, server: str) -> Optional[ServerDataDict]: + named = store.get_by_name(name) + if named is not None: + return named + for entry in store.get_all_servers(): + if _saved_server_kind(entry) == "connect" and _same_server(entry.get("url"), server): + return entry + return None + + +def _resolve_tls( + saved: Optional[ServerDataDict], insecure: bool, ca_data: Optional[str | bytes], client_id: Optional[str] +) -> tuple[bool, Optional[str | bytes], Optional[str]]: + tls_insecure, tls_ca = _tls_settings(saved, insecure, ca_data) + saved_id = saved.get("oauth_client_id") if saved else None + resolved_client_id = client_id if client_id is not None else (str(saved_id) if saved_id else None) + if resolved_client_id is not None: + resolved_client_id = _identifier(resolved_client_id, "OAuth client ID") + return tls_insecure, tls_ca, resolved_client_id + + +def _tls_settings( + saved: Optional[ServerDataDict], insecure: bool, ca_data: Optional[str | bytes] +) -> tuple[bool, Optional[str | bytes]]: + if insecure and ca_data is not None: + raise RSConnectException("Cannot combine insecure TLS with a custom CA certificate.") + if insecure or ca_data is not None: + tls_insecure, tls_ca = insecure, ca_data + elif saved: + tls_insecure, tls_ca = bool(saved.get("insecure")), saved.get("ca_cert") + else: + return False, None + if tls_insecure and tls_ca is not None: + raise RSConnectException("Cannot combine insecure TLS with a custom CA certificate.") + return tls_insecure, tls_ca + + +def _check_pending_target( + state: dict[str, Any], + kind: str, + server: str, + account: Optional[str], + client_id: Optional[str], + insecure: bool, + ca_data: Optional[str | bytes], +) -> None: + matches = state["server"] == server + if kind == "cloud": + matches = matches and state["account"] == account + if client_id: + matches = matches and state["client_id"] == client_id + if insecure or ca_data is not None: + matches = matches and state["insecure"] == insecure and _state_ca(state) == ca_data + if not matches: + raise RSConnectException('A pending "%s" login for "%s" has a different target.' % (kind, state["name"])) + + +def _remaining(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RSConnectException("Device login start exceeded its 120-second limit.") + return remaining + + +def _post_form( + endpoint: str, + fields: dict[str, str], + insecure: bool, + ca_data: Optional[str | bytes], + request_timeout: float, + request_deadline: Optional[float] = None, +) -> Any: + base, path = _endpoint_location(endpoint) + return _post_oauth_form_request( + base, + path, + fields, + insecure, + ca_data, + request_timeout, + request_deadline, + suppress_response_logging=True, + ) + + +def _device_authorization_response(response: Any) -> dict[str, Any]: + try: + return _unwrap_json_response(response) + except InvalidClientError: + raise + except RSConnectException: + raise RSConnectException("OAuth device authorization request failed.") from None + + +def _register_device_client( + metadata: dict[str, Any], + server: str, + insecure: bool, + ca_data: Optional[str | bytes], + deadline: float, +) -> str: + try: + client_id = register_client( + metadata, + server, + insecure, + ca_data, + request_timeout=_remaining(deadline), + request_deadline=deadline, + suppress_response_logging=True, + ) + except InvalidClientError: + raise + except RSConnectException: + raise RSConnectException("OAuth client registration failed.") from None + return _identifier(client_id, "OAuth client ID") + + +def _start_device_request( + metadata: dict[str, Any], + server: str, + client_id: str, + scope: Optional[str], + insecure: bool, + ca_data: Optional[str | bytes], + deadline: float, +) -> tuple[str, dict[str, Any]]: + client_id = _identifier(client_id, "OAuth client ID") + fields = {"client_id": client_id} + if scope: + fields["scope"] = scope + try: + response = _post_form( + metadata["device_authorization_endpoint"], + fields, + insecure, + ca_data, + _remaining(deadline), + request_deadline=deadline, + ) + return client_id, _device_authorization_response(response) + except InvalidClientError: + if not metadata.get("registration_endpoint"): + raise + client_id = _register_device_client(metadata, server, insecure, ca_data, deadline) + fields["client_id"] = client_id + response = _post_form( + metadata["device_authorization_endpoint"], + fields, + insecure, + ca_data, + _remaining(deadline), + request_deadline=deadline, + ) + return client_id, _device_authorization_response(response) + + +def _make_state( + kind: str, + name: str, + server: str, + metadata: dict[str, Any], + response: dict[str, Any], + client_id: str, + scope: Optional[str], + set_default: bool, + insecure: bool, + ca_data: Optional[str | bytes], + account: Optional[str], +) -> dict[str, Any]: + client_id = _identifier(client_id, "OAuth client ID") + device_code = response.get("device_code") + user_code = response.get("user_code") + verification = response.get("verification_uri_complete") or response.get("verification_uri") + if not all(isinstance(value, str) and value for value in (device_code, user_code, verification)): + raise RSConnectException("Device authorization returned an incomplete response.") + verification_uri = cast(str, verification) + expires_in = response.get("expires_in", 600) + interval = response.get("interval", 5) + if not _positive_finite_duration(expires_in) or not _positive_finite_duration(interval): + raise RSConnectException("Device authorization returned an invalid expiry or interval.") + ca_text, ca_b64 = _ca_values(ca_data) + now = time.time() + state = { + "version": 1, + "kind": kind, + "name": name, + "server": server, + "client_id": client_id, + "device_endpoint": metadata["device_authorization_endpoint"], + "token_endpoint": metadata["token_endpoint"], + "device_code": device_code, + "user_code": user_code, + "verification_uri": _verification_uri(verification_uri, urlsplit(server).scheme == "https"), + "expires_at": now + expires_in, + "interval": interval, + "last_poll_at": now, + "scope": scope, + "set_default": set_default, + "insecure": insecure, + "ca_data": ca_text, + "ca_data_b64": ca_b64, + "account": account, + "tokens": None, + } + return _validate_state(kind, name, state) + + +def _start_result(state: dict[str, Any]) -> dict[str, Any]: + return { + "status": "pending", + "name": state["name"], + "server": state["server"], + "verification_uri": state["verification_uri"], + "user_code": state["user_code"], + "expires_in": max(0, int(math.ceil(cast(float, state["expires_at"]) - time.time()))), + } + + +def _device_code_expired(state: dict[str, Any]) -> bool: + return state["tokens"] is None and state["expires_at"] <= time.time() + + +def start_connect_login( + url: str, + name: str, + insecure: bool = False, + ca_data: Optional[str | bytes] = None, + set_default: bool = True, + client_id: Optional[str] = None, +) -> dict[str, Any]: + require_posix("Resumable device login") + name = _identifier(name, "Nickname") + if client_id is not None: + client_id = _identifier(client_id, "OAuth client ID") + with _state_lock("connect", name, time.monotonic() + _STATE_LOCK_TIMEOUT): + _remove_temporary_state_files("connect", name) + return _start_connect_login(url, name, insecure, ca_data, set_default, client_id) + + +def _start_connect_login( + url: str, + name: str, + insecure: bool, + ca_data: Optional[str | bytes], + set_default: bool, + client_id: Optional[str], +) -> dict[str, Any]: + server = _normalize_server_url(url) + store = _store() + _assert_nickname_target(store, "connect", name, server) + state = _read_state("connect", name) + if state and _device_code_expired(state): + _remove_state("connect", name) + state = None + if state: + _check_pending_target(state, "connect", server, None, client_id, insecure, ca_data) + state["set_default"] = set_default + _write_state(state) + return _start_result(state) + + insecure, ca_data, client_id = _resolve_tls(_saved_connect_entry(store, name, server), insecure, ca_data, client_id) + deadline = time.monotonic() + _START_TIMEOUT + metadata = _metadata( + discover_oauth_metadata( + server, + insecure, + ca_data, + request_timeout=_remaining(deadline), + request_deadline=deadline, + suppress_response_logging=True, + ), + server, + ) + if not client_id: + client_id = _register_device_client(metadata, server, insecure, ca_data, deadline) + client_id, response = _start_device_request(metadata, server, client_id, None, insecure, ca_data, deadline) + _remaining(deadline) + state = _make_state( + "connect", name, server, metadata, response, client_id, None, set_default, insecure, ca_data, None + ) + _write_state(state) + return _start_result(state) + + +def start_cloud_login( + account: str, + name: str, + url: Optional[str] = None, + set_default: bool = False, +) -> dict[str, Any]: + require_posix("Resumable device login") + name = _identifier(name, "Nickname") + with _state_lock("cloud", name, time.monotonic() + _STATE_LOCK_TIMEOUT): + _remove_temporary_state_files("cloud", name) + return _start_cloud_login(account, name, url, set_default) + + +def _start_cloud_login( + account: str, + name: str, + url: Optional[str], + set_default: bool, +) -> dict[str, Any]: + account = _identifier(account, "Connect Cloud account") + server = _cloud_server_url(url) + store = _store() + _assert_nickname_target(store, "cloud", name, server, account) + state = _read_state("cloud", name) + if state and _device_code_expired(state): + _remove_state("cloud", name) + state = None + if state: + _check_pending_target(state, "cloud", server, account, None, False, None) + state["set_default"] = set_default + _write_state(state) + return _start_result(state) + + environment = connect_cloud.environment_for_url(server) + metadata = _metadata(connect_cloud.urls(environment).oauth_metadata(), server) + client_id = _identifier(connect_cloud.client_id(environment), "OAuth client ID") + deadline = time.monotonic() + _START_TIMEOUT + client_id, response = _start_device_request(metadata, server, client_id, connect_cloud.SCOPE, False, None, deadline) + _remaining(deadline) + state = _make_state( + "cloud", + name, + server, + metadata, + response, + client_id, + connect_cloud.SCOPE, + set_default, + False, + None, + account, + ) + _write_state(state) + return _start_result(state) + + +def _raise_device_error(code: Any) -> None: + error_type = _DEVICE_ERRORS.get(code) if isinstance(code, str) else None + if error_type is None: + raise RSConnectException("OAuth device token request failed.") + raise error_type() + + +def _response_data(response: Any) -> tuple[dict[str, Any], Optional[int]]: + data, status = _oauth_response_data(response) + if data is None: + raise RSConnectException("Device token request returned an unexpected response.") + return data, status + + +def _device_token_response(response: Any) -> dict[str, Any]: + data, status = _response_data(response) + if data.get("error"): + _raise_device_error(data["error"]) + _validate_token_success(data, status) + return data + + +def _validate_token_success(data: dict[str, Any], status: Optional[int]) -> None: + if status is None or status < 200 or status >= 300: + if status is None: + raise RSConnectException("OAuth device token request failed.") + raise RSConnectException("OAuth device token request failed (HTTP %s)." % status) + access_token = data.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise RSConnectException("Device token request returned no access token.") + + +def _poll_timeout(state: dict[str, Any], deadline: float) -> Optional[float]: + remaining = deadline - time.monotonic() + device_remaining = state["expires_at"] - time.time() + if device_remaining <= 0: + raise _Expired() + if remaining <= 0: + return None + wait = state["interval"] - (time.time() - state["last_poll_at"]) + if wait > 0: + time.sleep(min(wait, remaining, device_remaining)) + remaining = deadline - time.monotonic() + device_remaining = state["expires_at"] - time.time() + if device_remaining <= 0: + raise _Expired() + if remaining <= 0: + return None + return min(remaining, device_remaining) + + +def _record_poll_error(state: dict[str, Any], error: Exception) -> None: + if isinstance(error, _SlowDown): + state["interval"] += _POLL_INTERVAL_STEP + state["last_poll_at"] = time.time() + _write_state(state) + + +def _poll_for_token(state: dict[str, Any], deadline: float) -> Optional[dict[str, Any]]: + while True: + request_timeout = _poll_timeout(state, deadline) + if request_timeout is None: + return None + fields = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": state["client_id"], + "device_code": state["device_code"], + } + if state["scope"]: + fields["scope"] = state["scope"] + try: + request_deadline = min(deadline, time.monotonic() + request_timeout) + response = _post_form( + state["token_endpoint"], + fields, + state["insecure"], + _state_ca(state), + request_timeout, + request_deadline=request_deadline, + ) + tokens = _device_token_response(response) + except (_Pending, _SlowDown) as error: + _record_poll_error(state, error) + continue + except (_Denied, _Expired, InvalidClientError, InvalidGrantError): + raise + except Exception: + state["last_poll_at"] = time.time() + _write_state(state) + if deadline <= time.monotonic(): + raise _FinishDeadline() + raise + state["last_poll_at"] = time.time() + return tokens + + +def _checkpoint(tokens: dict[str, Any]) -> dict[str, Any]: + refresh = tokens.get("refresh_token") + if refresh is not None and not isinstance(refresh, str): + raise RSConnectException("OAuth device token response was invalid.") + expiry = tokens.get("expires_in") + try: + seconds = int(expiry) if expiry is not None else 0 + except (OverflowError, TypeError, ValueError): + seconds = 0 + expires_at = None + if seconds > 0 and not isinstance(expiry, bool): + try: + candidate = time.time() + seconds + if _valid_number(candidate): + expires_at = candidate + except OverflowError: + pass + return { + "access_token": tokens["access_token"], + "refresh_token": refresh, + "expires_at": expires_at, + } + + +def _pending(state: dict[str, Any]) -> dict[str, Any]: + return {"status": "pending", "name": state["name"], "server": state["server"]} + + +def _finalize_connect(state: dict[str, Any]) -> dict[str, Any]: + name, server, tokens = state["name"], state["server"], state["tokens"] + store = _store() + _assert_nickname_target(store, "connect", name, server) + saved = _saved_connect_entry(store, name, server) + if saved is not None: + # Deployment history and keyring entries use the exact saved URL. + server = saved["url"] + in_keyring = keyring_store_token(server, tokens["access_token"], tokens["refresh_token"]) + ca_data = _state_ca(state) + ca_text = ca_data.decode("utf-8") if isinstance(ca_data, bytes) else ca_data + store.set( + name, + server, + oauth_client_id=state["client_id"], + insecure=state["insecure"], + ca_data=ca_text, + oauth_access_token=None if in_keyring else tokens["access_token"], + oauth_refresh_token=None if in_keyring else tokens["refresh_token"], + oauth_token_expiry=tokens["expires_at"], + set_as_default=state["set_default"], + ) + return {"status": "done", "name": name, "server": state["server"]} + + +class _DeviceLoginCloudClient(api.ConnectCloudClient): + def _no_such_account(self, accounts: list[api.ConnectCloudAccount], not_found_message: str) -> RSConnectException: + error = super()._no_such_account(accounts, not_found_message) + return ConnectCloudAccountNotFoundError(error.message) + + +def _cloud_login_client( + state: dict[str, Any], deadline: float +) -> tuple[api.ConnectCloudServer, api.ConnectCloudClient, dict[str, Any]]: + tokens = state["tokens"] + cloud_server = api.ConnectCloudServer( + account_name=state["account"], + access_token=tokens["access_token"], + refresh_token=tokens["refresh_token"], + url=state["server"], + oauth_client_id=state["client_id"], + ) + client = _DeviceLoginCloudClient(cloud_server) + client._suppress_oauth_response_logging = True + client.request_timeout = deadline - time.monotonic() + client.request_deadline = deadline + if client.request_timeout <= 0: + raise _FinishDeadline() + return cloud_server, client, tokens + + +def _lookup_cloud_account( + client: api.ConnectCloudClient, account_name: str, deadline: float +) -> api.ConnectCloudAccount: + try: + with client: + account = client.get_account_by_name(account_name) + if deadline <= time.monotonic(): + raise _FinishDeadline() + except Exception as exc: + if isinstance(exc, ConnectCloudAccountNotFoundError): + raise + if _invalid_grant_error(exc): + raise InvalidGrantError() from exc + if deadline <= time.monotonic(): + raise _FinishDeadline() from exc + if isinstance(exc, RSConnectException): + status_detail = " (HTTP %s)" % exc.status if exc.status is not None else "" + safe_error = RSConnectException( + "Posit Connect Cloud account lookup failed%s." % status_detail, + cause=exc.cause, + status=exc.status, + ) + raise safe_error from None + raise + return account + + +def _checkpoint_rotated_cloud_tokens( + state: dict[str, Any], cloud_server: api.ConnectCloudServer, tokens: dict[str, Any] +) -> None: + if cloud_server.access_token == tokens["access_token"] and cloud_server.refresh_token == tokens["refresh_token"]: + return + state["tokens"] = { + "access_token": cloud_server.access_token, + "refresh_token": cloud_server.refresh_token, + "expires_at": tokens["expires_at"], + } + _write_state(state) + + +def _save_cloud_account( + state: dict[str, Any], cloud_server: api.ConnectCloudServer, account: api.ConnectCloudAccount +) -> dict[str, Any]: + name, server_url, account_name = state["name"], state["server"], state["account"] + store = _store() + _assert_nickname_target(store, "cloud", name, server_url, account_name) + in_keyring = connect_cloud.store_credentials_in_keyring( + server_url, name, cloud_server.access_token, cloud_server.refresh_token, None + ) + store.set( + name, + server_url, + connect_cloud_account_name=account_name, + connect_cloud_account_id=account["id"], + connect_cloud_access_token=None if in_keyring else cloud_server.access_token, + connect_cloud_refresh_token=None if in_keyring else cloud_server.refresh_token, + set_as_default=state["set_default"], + ) + return {"status": "done", "name": name, "server": server_url, "account": account_name} + + +def _finalize_cloud(state: dict[str, Any], deadline: float) -> dict[str, Any]: + cloud_server, client, tokens = _cloud_login_client(state, deadline) + try: + account = _lookup_cloud_account(client, state["account"], deadline) + finally: + _checkpoint_rotated_cloud_tokens(state, cloud_server, tokens) + return _save_cloud_account(state, cloud_server, account) + + +def _invalid_grant_error(error: BaseException) -> bool: + current: Optional[BaseException] = error + while current is not None: + if isinstance(current, InvalidGrantError): + return True + cause = getattr(current, "cause", None) or current.__cause__ + current = cause if isinstance(cause, BaseException) and cause is not current else None + return False + + +def _finish_state(state: dict[str, Any], deadline: float) -> dict[str, Any]: + if state["tokens"] is None: + tokens = _poll_for_token(state, deadline) + if tokens is None: + return _pending(state) + state["tokens"] = _checkpoint(tokens) + _write_state(state) + if deadline <= time.monotonic(): + return _pending(state) + if state["kind"] == "cloud": + return _finalize_cloud(state, deadline) + return _finalize_connect(state) + + +def _load_pending(kind: str, name: str) -> dict[str, Any]: + state = _read_state(kind, name) + if state is None: + raise RSConnectException('No pending "%s" login exists for nickname "%s".' % (kind, name)) + if _device_code_expired(state): + _remove_state(kind, name) + raise _Expired() + _assert_nickname_target(_store(), kind, name, state["server"], state["account"]) + return state + + +def _validate_finish_args(kind: str, name: str, timeout: int) -> str: + if kind not in ("connect", "cloud"): + raise RSConnectException('Login kind must be "connect" or "cloud".') + name = _identifier(name, "Nickname") + if type(timeout) is not int or timeout < 1: + raise RSConnectException("Login timeout must be a positive integer.") + return name + + +def finish_login(kind: str, name: str, timeout: int = 120) -> dict[str, Any]: + require_posix("Resumable device login") + name = _validate_finish_args(kind, name, timeout) + deadline = time.monotonic() + timeout + try: + with _state_lock(kind, name, deadline): + _remove_temporary_state_files(kind, name) + state = _load_pending(kind, name) + return _finish_and_cleanup(kind, name, state, deadline) + except _LockBusy: + return {"status": "pending", "name": name, "server": None} + + +def _finish_and_cleanup(kind: str, name: str, state: dict[str, Any], deadline: float) -> dict[str, Any]: + try: + result = _finish_state(state, deadline) + except _FinishDeadline: + return _pending(state) + except (_Denied, _Expired, InvalidClientError, InvalidGrantError, ConnectCloudAccountNotFoundError): + _remove_state(kind, name) + raise + if result["status"] == "done": + _remove_state(kind, name) + return result diff --git a/rsconnect/exception.py b/rsconnect/exception.py index 8353ac97..544dac19 100644 --- a/rsconnect/exception.py +++ b/rsconnect/exception.py @@ -13,3 +13,7 @@ def __init__(self, message: str, cause: Optional[Exception] = None, status: Opti class DeploymentFailedException(RSConnectException): pass + + +class ConnectCloudAccountNotFoundError(RSConnectException): + """A completed account lookup did not find the requested Cloud account.""" diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index 34be9bc7..c379584b 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -10,6 +10,8 @@ import re import socket import ssl +import threading +import time from http import client as http from http.cookies import SimpleCookie from typing import IO, Any, Dict, List, Mapping, Optional, Tuple, Union, cast @@ -314,6 +316,28 @@ def create_multipart_form_data( return body, content_type +def _interrupt_socket(sock: Optional[socket.socket]) -> None: + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + +def _close_failed_request( + connection: Union[http.HTTPConnection, http.HTTPSConnection], response: Optional[http.HTTPResponse] +) -> None: + if response is not None: + try: + response.close() + except OSError: + pass + try: + connection.close() + except OSError: + pass + + class HTTPResponse(object): """ This class represents the result of executing an HTTP request. @@ -387,6 +411,8 @@ def __init__( disable_tls_check: bool = False, ca_data: Optional[str | bytes] = None, cookies: Optional[CookieJar] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, ): """ Constructs an HTTPServer object. @@ -399,6 +425,9 @@ def __init__( certificates. :param cookies: an optional cookie jar. Must be of type `CookieJar` defined in this same file (i.e., not the one Python provides). + :param request_timeout: an optional socket timeout for this client, in seconds. + :param request_deadline: an optional absolute time.monotonic() deadline across requests. + When omitted, CONNECT_REQUEST_TIMEOUT supplies the timeout. """ self._url = urlparse(url) @@ -410,6 +439,9 @@ def __init__( self._cookies = cookies if cookies is not None else CookieJar() self._headers = {"User-Agent": _user_agent} self._conn = None + self.request_timeout = request_timeout + self.request_deadline = request_deadline + self._suppress_oauth_response_logging = False self._proxy_headers = _get_proxy_headers() self._inject_cookies() @@ -446,6 +478,8 @@ def __enter__(self): self._disable_tls_check, self._ca_data, ) + if self.request_timeout is not None: + self._conn.timeout = self.request_timeout return self def __exit__(self, *args: object): @@ -524,6 +558,118 @@ def request( def get_extra_headers(self, url: str, method: str, body: str | bytes | IO[bytes] | None) -> dict[str, str]: return {} + def _apply_request_deadline(self) -> None: + if self.request_deadline is None: + return + remaining = self.request_deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("HTTP request deadline exceeded.") + timeout = self.request_timeout if self.request_timeout is not None else get_request_timeout() + self.request_timeout = min(timeout, remaining) if timeout else remaining + if self._conn is not None: + self._conn.timeout = self.request_timeout + if self._conn.sock is not None: + self._conn.sock.settimeout(self.request_timeout) + + def _request_with_deadline( + self, + connection: Union[http.HTTPConnection, http.HTTPSConnection], + method: str, + full_uri: str, + body: str | bytes | IO[bytes] | None, + headers: dict[str, str], + ) -> Tuple[http.HTTPResponse, bytes]: + deadline = self.request_deadline + if deadline is None: + connection.request(method, full_uri, body, headers) + response = connection.getresponse() + return response, response.read() + + self._apply_request_deadline() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise socket.timeout("HTTP request deadline exceeded.") + + # ponytail: shutdown cannot cancel OS DNS or blocked source reads; revisit if either must fit this deadline. + request_socket = connection.sock + request_completed = False + request_lock = threading.Lock() + + def interrupt_request() -> None: + with request_lock: + if not request_completed: + _interrupt_socket(request_socket if request_socket is not None else connection.sock) + + timer = threading.Timer(remaining, interrupt_request) + timer.daemon = True + timer.start() + response: Optional[http.HTTPResponse] = None + try: + connection.request(method, full_uri, body, headers) + with request_lock: + request_socket = connection.sock or request_socket + if time.monotonic() >= deadline: + raise socket.timeout("HTTP request deadline exceeded.") + response = connection.getresponse() + with request_lock: + if time.monotonic() >= deadline: + raise socket.timeout("HTTP request deadline exceeded.") + response_body = response.read() + with request_lock: + # A complete token response must reach its checkpoint even after the deadline. + request_completed = True + return response, response_body + except Exception: + if time.monotonic() >= deadline: + raise socket.timeout("HTTP request deadline exceeded.") from None + raise + finally: + timer.cancel() + timer.join() + if not request_completed: + _close_failed_request(connection, response) + + def _log_request( + self, + method: str, + full_uri: str, + headers: dict[str, str], + body: str | bytes | IO[bytes] | None, + suppress_uri_logging: bool, + ) -> None: + if not logger.is_debugging(): + return + private_uri = suppress_uri_logging or self._suppress_oauth_response_logging + logged_uri = "" if private_uri else _redacted_uri_for_log(full_uri) + logger.debug(f"Request: {method} {logged_uri}") + logger.debug("Headers:") + for key, value in headers.items(): + logger.debug(f"--> {key}: {_redacted_header_for_log(key, value)}") + logger.debug("Body:") + logger.debug(f"--> {_redacted_body_for_log(body) if body is not None else ''}") + + def _log_response(self, response: http.HTTPResponse, response_body: str | bytes) -> None: + if not logger.is_debugging(): + return + if self._suppress_oauth_response_logging: + logger.debug(f"Response: {response.status}") + else: + logger.debug(f"Response: {response.status} {response.reason}") + logger.debug("Headers:") + if self._suppress_oauth_response_logging: + logger.debug("--> ") + else: + for key, value in response.getheaders(): + logger.debug(f"--> {key}: {_redacted_header_for_log(key, value)}") + logger.debug("Body:") + if self._suppress_oauth_response_logging: + logger.debug("--> ") + elif response.getheader("Content-Type", "").startswith("application/json"): + # Only print JSON responses. Otherwise we end up dumping entire web pages to the log. + logger.debug(f"--> {_redacted_body_for_log(response_body)}") + else: + logger.debug("--> ") + def _do_request( self, method: str, @@ -533,6 +679,7 @@ def _do_request( maximum_redirects: int, extra_headers: dict[str, str], decode_response: bool = True, + _suppress_uri_logging: bool = False, ) -> JsonData | HTTPResponse: full_uri = path if query_params is not None: @@ -545,13 +692,8 @@ def _do_request( local_connection = False try: - if logger.is_debugging(): - logger.debug(f"Request: {method} {_redacted_uri_for_log(full_uri)}") - logger.debug("Headers:") - for key, value in headers.items(): - logger.debug(f"--> {key}: {_redacted_header_for_log(key, value)}") - logger.debug("Body:") - logger.debug(f"--> {_redacted_body_for_log(body) if body is not None else ''}") + self._apply_request_deadline() + self._log_request(method, full_uri, headers, body, _suppress_uri_logging) # if we weren't called under a `with` statement, we'll need to manage the # connection here. @@ -563,62 +705,23 @@ def _do_request( conn = cast(Union[http.HTTPConnection, http.HTTPSConnection], self._conn) try: - conn.request(method, full_uri, body, headers) - - response = conn.getresponse() - response_body = response.read() + response, response_body = self._request_with_deadline(conn, method, full_uri, body, headers) if decode_response: response_body = response_body.decode("utf-8").strip() - if logger.is_debugging(): - logger.debug(f"Response: {response.status} {response.reason}") - logger.debug("Headers:") - for key, value in response.getheaders(): - logger.debug(f"--> {key}: {_redacted_header_for_log(key, value)}") - logger.debug("Body:") - if response.getheader("Content-Type", "").startswith("application/json"): - # Only print JSON responses. - # Otherwise we end up dumping entire web pages to the log. - try: - logger.debug(f"--> {_redacted_body_for_log(response_body)}") - except json.JSONDecodeError: - logger.debug("--> ") - else: - logger.debug("--> ") + self._log_response(response, response_body) finally: if local_connection: self.__exit__() # Handle any redirects. if 300 <= response.status < 400: - if maximum_redirects == 0: - raise http.CannotSendRequest("Too many redirects") - - location = response.getheader("Location") - - if location is None: - raise http.CannotSendRequest("Redirect response missing Location header") - - # Assume the redirect location will always be on the same domain. - if location.startswith("http"): - parsed_location = urlparse(location) - if parsed_location.query: - next_url = f"{parsed_location.path}?{parsed_location.query}" - else: - next_url = parsed_location.path - else: - next_url = location - - logger.debug(f"--> Redirected to: {_redacted_uri_for_log(urljoin(self._url.geturl(), location))}") - - redirect_extra_headers = self.get_extra_headers(next_url, "GET", body) - return self._do_request( - "GET", - next_url, + return self._follow_redirect( + response, query_params, body, - maximum_redirects - 1, - {**extra_headers, **redirect_extra_headers}, + maximum_redirects, + extra_headers, ) self._handle_set_cookie(response) @@ -634,20 +737,71 @@ def _do_request( socket.gaierror, socket.timeout, ) as exception: - logger.debug("An exception occurred processing the HTTP request.", exc_info=True) + if self._suppress_oauth_response_logging: + logger.debug("An exception occurred processing the HTTP request (%s)." % type(exception).__name__) + else: + logger.debug("An exception occurred processing the HTTP request.", exc_info=True) return HTTPResponse(full_uri, exception=exception) + def _follow_redirect( + self, + response: http.HTTPResponse, + query_params: Optional[Mapping[str, JsonData]], + body: str | bytes | IO[bytes] | None, + maximum_redirects: int, + extra_headers: dict[str, str], + ) -> JsonData | HTTPResponse: + if maximum_redirects == 0: + raise http.CannotSendRequest("Too many redirects") + + location = response.getheader("Location") + if location is None: + raise http.CannotSendRequest("Redirect response missing Location header") + + # Assume the redirect location will always be on the same domain. + if location.startswith("http"): + parsed_location = urlparse(location) + if parsed_location.query: + next_url = f"{parsed_location.path}?{parsed_location.query}" + else: + next_url = parsed_location.path + else: + next_url = location + + if self._suppress_oauth_response_logging: + logger.debug("--> Following HTTP redirect") + else: + logger.debug(f"--> Redirected to: {_redacted_uri_for_log(urljoin(self._url.geturl(), location))}") + + redirect_extra_headers = self.get_extra_headers(next_url, "GET", body) + return self._do_request( + "GET", + next_url, + query_params, + body, + maximum_redirects - 1, + {**extra_headers, **redirect_extra_headers}, + _suppress_uri_logging=self._suppress_oauth_response_logging, + ) + # noinspection PyMethodMayBeStatic def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse: return response def _handle_set_cookie(self, response: http.HTTPResponse): - self._cookies.store_cookies(response) + if self._suppress_oauth_response_logging: + self._cookies.store_cookies(response, suppress_logs=True) + else: + self._cookies.store_cookies(response) self._inject_cookies() def _inject_cookies(self): if len(self._cookies) > 0: - self._headers["Cookie"] = self._cookies.get_cookie_header_value() + self._headers["Cookie"] = ( + self._cookies.get_cookie_header_value(suppress_logs=True) + if self._suppress_oauth_response_logging + else self._cookies.get_cookie_header_value() + ) elif "Cookie" in self._headers: del self._headers["Cookie"] @@ -725,7 +879,7 @@ def __init__(self) -> None: self._content: dict[str, str] = {} self._reference = SimpleCookie() - def store_cookies(self, response: http.HTTPResponse): + def store_cookies(self, response: http.HTTPResponse, *, suppress_logs: bool = False): headers = filter(lambda h: h[0].lower() == "set-cookie", response.getheaders()) for header in headers: @@ -735,13 +889,16 @@ def store_cookies(self, response: http.HTTPResponse): self._keys.append(morsel.key) self._content[morsel.key] = morsel.value # Cookies are session credentials; names only, like the header log. - logger.debug(f"--> Set cookie {morsel.key}: ") + if not suppress_logs: + logger.debug(f"--> Set cookie {morsel.key}: ") - logger.debug(f"CookieJar contents: {self._keys}") + if not suppress_logs: + logger.debug(f"CookieJar contents: {self._keys}") - def get_cookie_header_value(self): + def get_cookie_header_value(self, *, suppress_logs: bool = False): result = "; ".join([f"{key}={self._reference.value_encode(self._content[key])[1]}" for key in self._keys]) - logger.debug(f"Cookie: {'; '.join(f'{key}=' for key in self._keys)}") + if not suppress_logs: + logger.debug(f"Cookie: {'; '.join(f'{key}=' for key in self._keys)}") return result def as_dict(self): diff --git a/rsconnect/main.py b/rsconnect/main.py index 3531eb84..7cb64dc5 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2,6 +2,7 @@ import functools import json +import logging import os import shutil import subprocess @@ -215,6 +216,198 @@ def output_params( logger.log(VERBOSE, " %-18s%s (from %s)", (k + ":"), val, sourceName) +def device_login_args(kind: Literal["connect", "cloud"]) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Add resumable login options without changing the blocking command path.""" + + def decorate(func: Callable[..., Any]) -> Callable[..., Any]: + @click.option("--no-wait", is_flag=True, help="Start device login, print approval details as JSON, and exit.") + @click.option("--finish", is_flag=True, help="Finish a pending device login selected by --name.") + @click.option( + "--timeout", + type=click.IntRange(min=1), + default=120, + show_default=True, + help="Maximum seconds for the entire finish invocation, including account lookup. Requires --finish.", + ) + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + no_wait = kwargs.pop("no_wait", False) + finish = kwargs.pop("finish", False) + timeout = kwargs.pop("timeout", 120) + timeout_supplied = ( + validation.get_parameter_source_name_from_ctx("timeout", click.get_current_context()) == "COMMANDLINE" + ) + resumable = no_wait or finish + if resumable or timeout_supplied: + _set_json_verbosity(kwargs.get("verbose", 0)) + if timeout_supplied and not finish: + raise RSConnectException("--timeout requires --finish.") + if not resumable: + return func(*args, **kwargs) + if no_wait and finish: + raise RSConnectException("Specify only one of --no-wait or --finish.") + logger.log(VERBOSE, "Finishing device authentication." if finish else "Starting device authentication.") + result = _run_device_login(kind, kwargs, finish, timeout) + click.echo(json.dumps(result)) + + return wrapper + + return decorate + + +def _set_json_verbosity(verbose: int) -> None: + """Reserve stdout for JSON while allowing diagnostics on stderr.""" + set_verbosity(0, quiet=True) + if verbose: + logger.setLevel(VERBOSE if verbose == 1 else logging.DEBUG) + + +def preflight_exception_handler(func: Callable[P, T]) -> Callable[P, T]: + """Report operational preflight failures separately from incompatibility.""" + + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs): + try: + return func(*args, **kwargs) + except click.UsageError as exc: + exc.show() + sys.exit(exc.exit_code) + except RSConnectException as exc: + click.echo( + json.dumps( + { + "status": "error", + "runtime": click.get_current_context().params.get("runtime", "python"), + "error": exc.message, + "changed_files": [], + "warnings": [], + "actions": ["Resolve the reported error before retrying preflight."], + } + ) + ) + sys.exit(1) + finally: + logger.set_in_feedback(False) + + return wrapper + + +def _validate_preflight_options(ctx: click.Context) -> None: + params = ctx.params + if params["new"] and params["app_id"]: + raise click.UsageError("Specify only one of --new or --app-id.") + if params["runtime"] == "nodejs": + if params["fix"]: + raise click.UsageError("--fix applies only to Python projects; Node.js metadata is left unchanged.") + elif params["node"]: + raise click.UsageError("--node requires --runtime nodejs.") + + +def _run_device_login( + kind: Literal["connect", "cloud"], params: dict[str, Any], finish: bool, timeout: int +) -> dict[str, Any]: + validation.require_posix("Resumable device login") + from .device_login import finish_login + + name = params.get("name") + if kind == "cloud" and not ( + params.get("connect_cloud") or connect_cloud_auth.is_connect_cloud_url(params.get("server")) + ): + raise RSConnectException("--no-wait and --finish on add require --connect-cloud.") + if finish: + if not name: + raise RSConnectException("--finish requires --name to select the pending login.") + _validate_device_login_finish(params) + return finish_login(kind, name, timeout) + if kind == "connect": + return _start_connect_device_login(params) + return _start_cloud_device_login(params) + + +def _validate_device_login_finish(params: dict[str, Any]) -> None: + ctx = click.get_current_context() + start_options = ( + "server_arg", + "server", + "account", + "identity_token", + "identity_token_file", + "client_id", + "client_secret", + "insecure", + "cacert", + "set_default", + "no_set_default", + "use_device_code", + "api_key", + "snowflake_connection_name", + "token", + "secret", + ) + for option in start_options: + if params.get(option) and validation.get_parameter_source_name_from_ctx(option, ctx) == "COMMANDLINE": + raise RSConnectException("--finish uses the saved login options; pass only --name and --timeout.") + + +def _start_connect_device_login(params: dict[str, Any]) -> dict[str, Any]: + from urllib.parse import urlparse + + from .device_login import start_connect_login + + if params.get("identity_token") or params.get("identity_token_file"): + raise RSConnectException("--no-wait cannot be combined with identity token authentication.") + ctx = click.get_current_context() + server: Optional[str] = params.get("server") + positional: Optional[str] = params.get("server_arg") + if positional and validation.get_parameter_source_name_from_ctx("server", ctx) == "COMMANDLINE": + raise RSConnectException("You must specify only one of SERVER or -s/--server.") + server = positional or server + if not server: + raise RSConnectException("You must specify the server as a SERVER argument or with -s/--server.") + ca_data = read_certificate_file(params["cacert"]) if params.get("cacert") else None + return start_connect_login( + server, + params.get("name") or urlparse(server).hostname or server, + insecure=params["insecure"], + ca_data=ca_data, + set_default=not params["no_set_default"], + client_id=params.get("client_id"), + ) + + +def _start_cloud_device_login(params: dict[str, Any]) -> dict[str, Any]: + from .device_login import start_cloud_login + + if params.get("client_id") or params.get("client_secret"): + raise RSConnectException("--no-wait cannot be combined with service account credentials.") + ctx = click.get_current_context() + account = validation.effective_connect_cloud_account(ctx, params.get("account")) + if not account: + raise RSConnectException("--no-wait requires --account for Posit Connect Cloud.") + server: Optional[str] = params.get("server") + validation.validate_connection_options( + ctx=ctx, + url=server, + api_key=params.get("api_key"), + insecure=params["insecure"], + cacert=params.get("cacert"), + account_name=account, + token=params.get("token"), + secret=params.get("secret"), + snowflake_connection_name=params.get("snowflake_connection_name"), + connect_cloud=params["connect_cloud"], + ) + from_environment = validation.get_parameter_source_name_from_ctx("server", ctx) == "ENVIRONMENT" + if not connect_cloud_auth.is_connect_cloud_url(server) or (params.get("connect_cloud") and from_environment): + server = connect_cloud_auth.SERVER_NAME + return start_cloud_login( + account, + params.get("name") or "cloud", + url=connect_cloud_auth.resolve_url(server), + set_default=params["set_default"], + ) + + def server_args(func: Callable[P, T]) -> Callable[P, T]: @click.option("--name", "-n", help="The nickname of the Posit Connect server to deploy to.") @click.option( @@ -853,7 +1046,7 @@ def bootstrap( help=( "Associate a simple nickname with the information needed to interact with a deployment target. " "Specifying an existing nickname will cause its stored information to be replaced by what is given " - "on the command line." + "on the command line. Resumable device login requires the nickname to identify the same target." ), no_args_is_help=True, ) @@ -862,6 +1055,7 @@ def bootstrap( @spcs_args @cloud_shinyapps_args @connect_cloud_args +@device_login_args("cloud") @click.option( "--set-default", is_flag=True, @@ -1144,6 +1338,72 @@ def details( click.echo(" APIs: %sallowed" % ("" if apis_allowed else "not ")) +@cli.command( + short_help="Check a project's runtime availability on Posit Connect.", + help=( + "Report server runtime versions and the project's runtime constraint as JSON. " + "This command supports Posit Connect, including OAuth credentials; it does not support Posit Connect Cloud. " + "No project files change unless --fix is given. An incompatible constraint exits with status 3; " + "operational errors exit with status 1." + ), +) +@server_args +@spcs_args +@click.argument("directory", default=".", type=click.Path(exists=True)) +@click.option("--fix", is_flag=True, help="For new content without a constraint, create a suggested .python-version.") +@click.option("--new", is_flag=True, help="Check a new content item even when a deployment record exists.") +@click.option("--app-id", "-a", help="Check an existing content item by ID or GUID.") +@click.option("--runtime", type=click.Choice(["python", "nodejs"]), default="python", show_default=True) +@click.option("--node", type=click.Path(exists=True, dir_okay=False), help="Node.js executable for --runtime nodejs.") +@cli_exception_handler +@preflight_exception_handler +@click.pass_context +def preflight( + ctx: click.Context, + directory: str, + fix: bool, + new: bool, + app_id: Optional[str], + runtime: str, + node: Optional[str], + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + verbose: int, +): + from .preflight import load_preflight_app_store, run_preflight + + _set_json_verbosity(verbose) + validation.require_posix("Deployment preflight") + logger.log(VERBOSE, "Checking %s runtime availability.", runtime) + _validate_preflight_options(ctx) + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + path=directory, + app_id=app_id, + new=new, + app_store=load_preflight_app_store(directory), + ).validate_server() + if runtime == "nodejs": + from .preflight_node import run_node_preflight + + result = run_node_preflight(ce, directory, node) + else: + result = run_preflight(ce, directory, fix) + click.echo(json.dumps(result)) + if result["status"] == "incompatible": + sys.exit(3) + + @cli.command( short_help="Remove the information about a Posit Connect server.", help=( @@ -1378,6 +1638,7 @@ def _login_with_token_exchange( ) @click.option("--verbose", "-v", count=True, help="Enable verbose output. Use -vv for very verbose (debug) output.") @cli_exception_handler +@device_login_args("connect") def login( server_arg: Optional[str], server: Optional[str], diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index efdf0012..0905229f 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -10,6 +10,7 @@ import json import os import shutil +import stat import sys from datetime import datetime, timezone from io import BufferedWriter @@ -51,6 +52,9 @@ SHINYAPPS_SERVER_NAME = "shinyapps.io" SHINYAPPS_API_URL = "https://api.shinyapps.io" +# App deployment history is small; strict reads cap it at 1 MiB. +_MAX_METADATA_BYTES = 1024 * 1024 + def resolve_server_alias(url: str) -> str: """Translate a --server value into the URL a saved server is stored under. @@ -111,15 +115,25 @@ class DataStore(Generic[T]): an optional secondary one. """ - def __init__(self, primary_path: str, secondary_path: Optional[str] = None, chmod: bool = False): + def __init__( + self, + primary_path: str, + secondary_path: Optional[str] = None, + chmod: bool = False, + *, + autoload: bool = True, + strict_read: bool = False, + ): self._primary_path = primary_path self._secondary_path = secondary_path self._chmod = chmod + self._strict_read = strict_read self._data: dict[str, T] = {} self._real_path: str | None = None self._lock = Lock() - self.load() + if autoload: + self.load() def count(self): """ @@ -135,6 +149,8 @@ def _load_from(self, path: str): Returns True if the data was successfully loaded. """ + if self._strict_read: + return self._load_from_strict(path) if exists(path): with open(path, "rb") as f: self._data = json.loads(f.read().decode("utf-8")) @@ -142,6 +158,42 @@ def _load_from(self, path: str): return True return False + def _load_from_strict(self, path: str) -> bool: + try: + before = os.lstat(path) + if stat.S_ISLNK(before.st_mode): + raise OSError(f"Refusing to read symlinked metadata file: {path}") + if not stat.S_ISREG(before.st_mode): + raise OSError(f"Refusing to read non-regular metadata file: {path}") + if before.st_size > _MAX_METADATA_BYTES: + raise OSError(f"Metadata file exceeds the {_MAX_METADATA_BYTES}-byte limit: {path}") + + # ponytail: Windows lacks O_NOFOLLOW; use a native opener for stronger reparse-point race protection. + flags = ( + os.O_RDONLY + | getattr(os, "O_BINARY", 0) + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_CLOEXEC", 0) + ) + descriptor = os.open(path, flags) + except (FileNotFoundError, NotADirectoryError): + return False + with os.fdopen(descriptor, "rb") as metadata_file: + opened = os.fstat(metadata_file.fileno()) + if not stat.S_ISREG(opened.st_mode): + raise OSError(f"Refusing to read non-regular metadata file: {path}") + if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): + raise OSError(f"Metadata file changed while opening: {path}") + if opened.st_size > _MAX_METADATA_BYTES: + raise OSError(f"Metadata file exceeds the {_MAX_METADATA_BYTES}-byte limit: {path}") + data = metadata_file.read(_MAX_METADATA_BYTES + 1) + if len(data) > _MAX_METADATA_BYTES: + raise OSError(f"Metadata file exceeds the {_MAX_METADATA_BYTES}-byte limit: {path}") + self._data = json.loads(data.decode("utf-8")) + self._real_path = path + return True + def load(self): """ Load the data from a file. If the primary file doesn't exist, load it @@ -774,11 +826,13 @@ class AppStore(DataStore[AppMetadata]): are made. """ - def __init__(self, app_file: str, version: int = 1): + def __init__(self, app_file: str, version: int = 1, *, autoload: bool = True, strict_read: bool = False): base_name = str(basename(app_file).rsplit(".", 1)[0]) + ".json" super(AppStore, self).__init__( join(dirname(app_file), "rsconnect-python", base_name), join(config_dirname(), "applications", sha1(abspath(app_file)) + ".json"), + autoload=autoload, + strict_read=strict_read, ) self.version = version diff --git a/rsconnect/oauth.py b/rsconnect/oauth.py index 3ce8bc60..af097cbd 100644 --- a/rsconnect/oauth.py +++ b/rsconnect/oauth.py @@ -79,10 +79,60 @@ def _unwrap_json_response(response: Any) -> dict[str, Any]: raise RSConnectException("Unexpected OAuth response format.") +def _oauth_response_data(response: Any) -> tuple[Optional[dict[str, Any]], Optional[int]]: + """Return an OAuth response's JSON object and HTTP status when available.""" + if isinstance(response, HTTPResponse): + data = response.json_data + return (cast(Dict[str, Any], data) if isinstance(data, dict) else None, response.status) + if isinstance(response, dict): + return cast(Dict[str, Any], response), 200 + return None, None + + +def _blocking_device_code_response_data(response: Any) -> dict[str, Any]: + data, status = _oauth_response_data(response) + if data is not None: + return data + if isinstance(response, HTTPResponse): + raise RSConnectException(f"Device code token request failed: HTTP {status}.") + raise RSConnectException("Device code token request returned an unexpected response.") + + +def _post_oauth_form_request( + base_url: str, + path: str, + params: Mapping[str, str], + insecure: bool = False, + ca_data: Optional[str | bytes] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + *, + suppress_response_logging: bool = False, +) -> Any: + """POST a form and return the raw response so device polling can inspect pending errors.""" + server = HTTPServer(base_url, disable_tls_check=insecure, ca_data=ca_data) + server._suppress_oauth_response_logging = suppress_response_logging + if request_timeout is not None: + server.request_timeout = request_timeout + if request_deadline is not None: + server.request_deadline = request_deadline + with server: + return server.request( + "POST", + path, + body=urlencode(params).encode("utf-8"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + def discover_oauth_metadata( url: str, insecure: bool = False, ca_data: Optional[str | bytes] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + *, + suppress_response_logging: bool = False, ) -> dict[str, Any]: """Fetch OAuth 2.0 Authorization Server Metadata (RFC 8414). @@ -90,6 +140,9 @@ def discover_oauth_metadata( the server does not support OAuth. """ server = HTTPServer(url, disable_tls_check=insecure, ca_data=ca_data) + server._suppress_oauth_response_logging = suppress_response_logging + server.request_timeout = request_timeout + server.request_deadline = request_deadline with server: response = server.get("/.well-known/oauth-authorization-server") @@ -115,6 +168,10 @@ def register_client( url: str, insecure: bool = False, ca_data: Optional[str | bytes] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + *, + suppress_response_logging: bool = False, ) -> str: """Register an OAuth client via Dynamic Client Registration (RFC 7591). @@ -133,6 +190,9 @@ def register_client( grant_types.append("urn:ietf:params:oauth:grant-type:device_code") server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) + server._suppress_oauth_response_logging = suppress_response_logging + server.request_timeout = request_timeout + server.request_deadline = request_deadline with server: response = server.post( path, @@ -175,24 +235,20 @@ def _exchange_code_for_token( base = f"{parsed.scheme}://{parsed.netloc}" path = parsed.path - body = urlencode( - { - "grant_type": "authorization_code", - "client_id": client_id, - "code": code, - "redirect_uri": redirect_uri, - "code_verifier": code_verifier, - } - ).encode("utf-8") - - server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) - with server: - response = server.request( - "POST", - path, - body=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + params = { + "grant_type": "authorization_code", + "client_id": client_id, + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + response = _post_oauth_form_request( + base, + path, + params, + insecure, + ca_data, + ) data = _unwrap_json_response(response) if "access_token" not in data: @@ -337,16 +393,7 @@ def login_with_device_code( params = {"client_id": client_id} if scope: params["scope"] = scope - body = urlencode(params).encode("utf-8") - - server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) - with server: - response = server.request( - "POST", - path, - body=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + response = _post_oauth_form_request(base, path, params, insecure, ca_data) resp = _unwrap_json_response(response) device_code = str(resp.get("device_code", "")) @@ -399,29 +446,9 @@ def _poll_for_device_token( } if scope: params["scope"] = scope - body = urlencode(params).encode("utf-8") - - server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) - with server: - response = server.request( - "POST", - path, - body=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + response = _post_oauth_form_request(base, path, params, insecure, ca_data) - # Extract JSON from the response (raw HTTPServer always returns HTTPResponse) - json_data: Optional[dict[str, Any]] = None - if isinstance(response, HTTPResponse): - if isinstance(response.json_data, dict): - json_data = response.json_data - else: - raise RSConnectException(f"Device code token request failed: HTTP {response.status}.") - elif isinstance(response, dict): - json_data = response - - if json_data is None: - raise RSConnectException("Device code token request returned an unexpected response.") + json_data = _blocking_device_code_response_data(response) if "access_token" in json_data: return json_data @@ -454,6 +481,10 @@ def refresh_access_token( insecure: bool = False, ca_data: Optional[str | bytes] = None, scope: Optional[str] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + *, + suppress_response_logging: bool = False, ) -> dict[str, Any]: """Refresh an OAuth access token using a refresh token. @@ -469,7 +500,14 @@ def refresh_access_token( if scope: params["scope"] = scope - data = _post_token_request(str(metadata["token_endpoint"]), params, insecure, ca_data) + request_options: dict[str, Any] = {} + if request_timeout is not None: + request_options["request_timeout"] = request_timeout + if request_deadline is not None: + request_options["request_deadline"] = request_deadline + if suppress_response_logging: + request_options["suppress_response_logging"] = True + data = _post_token_request(str(metadata["token_endpoint"]), params, insecure, ca_data, **request_options) if "access_token" not in data: raise RSConnectException("Token refresh returned an unexpected response.") @@ -510,19 +548,26 @@ def _post_token_request( params: dict[str, str], insecure: bool = False, ca_data: Optional[str | bytes] = None, + request_timeout: Optional[float] = None, + request_deadline: Optional[float] = None, + *, + suppress_response_logging: bool = False, ) -> dict[str, Any]: """POST a form-encoded request to an OAuth token endpoint and return the JSON body.""" parsed = urlparse(token_endpoint) base = f"{parsed.scheme}://{parsed.netloc}" - server = HTTPServer(base, disable_tls_check=insecure, ca_data=ca_data) - with server: - response = server.request( - "POST", - parsed.path, - body=urlencode(params).encode("utf-8"), - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + logging_options = {"suppress_response_logging": True} if suppress_response_logging else {} + response = _post_oauth_form_request( + base, + parsed.path, + params, + insecure, + ca_data, + request_timeout, + request_deadline, + **logging_options, + ) return _unwrap_json_response(response) diff --git a/rsconnect/preflight.py b/rsconnect/preflight.py new file mode 100644 index 00000000..5ce4e47b --- /dev/null +++ b/rsconnect/preflight.py @@ -0,0 +1,610 @@ +"""Native preflight checks for Python deployments to Posit Connect.""" + +from __future__ import annotations + +import configparser +import os +import platform +import shutil +from pathlib import Path +from typing import Any, List, Mapping, Optional, Tuple, Union, cast + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version + +from .api import ConnectCloudServer, RSConnectClient, RSConnectExecutor, RSConnectServer, SPCSConnectServer +from .environment import fake_module_file_from_directory +from .exception import RSConnectException +from .metadata import AppStore +from .validation import require_posix +from .pyproject import ( + InvalidVersionConstraintError, + TOMLDecodeError, + detect_python_version_requirement, + get_python_version_requirement_parser, + lookup_metadata_file, +) + +_METADATA_FILES = (".python-version", "pyproject.toml", "setup.cfg") +_APP_STORE_READ_ERRORS = (OSError, ValueError, RecursionError, AttributeError, TypeError) + + +def _invalid_declared_metadata(project_path: Path) -> Optional[str]: + for filename, metadata_file in lookup_metadata_file(project_path): + parser = get_python_version_requirement_parser(metadata_file) + try: + constraint = parser(metadata_file) + except InvalidVersionConstraintError: + return filename + if constraint is None: + continue + if not isinstance(constraint, str) or not constraint.strip(): + return filename + try: + SpecifierSet(constraint) + except InvalidSpecifier: + return filename + return None + return None + + +def _project_constraint(project: str, warnings: list[str]) -> tuple[Optional[str], Optional[str]]: + project_path = Path(project) + for filename in _METADATA_FILES: + if (project_path / filename).is_symlink(): + warnings.append(f"{filename} is a symlink; refusing to read it.") + return None, filename + try: + constraint = detect_python_version_requirement(project) + invalid_metadata = _invalid_declared_metadata(project_path) + except (OSError, UnicodeError, TOMLDecodeError, configparser.Error, AttributeError) as err: + warnings.append(f"Could not read Python version metadata in {project}: {err}") + return None, "Python version metadata" + if invalid_metadata is None and constraint is None and os.path.lexists(project_path / ".python-version"): + invalid_metadata = ".python-version" + if invalid_metadata: + warnings.append(f"{invalid_metadata} has an unusable Python requirement; it was left unchanged.") + if not isinstance(constraint, str): + constraint = None + return constraint, invalid_metadata + + +def _server_python_versions(settings: Any) -> tuple[list[str], list[str], bool, bool]: + if not isinstance(settings, Mapping): + return [], [], False, False + settings_mapping = cast(Mapping[str, Any], settings) + raw_installations = settings_mapping.get("installations") + if not isinstance(raw_installations, list): + return [], [], False, False + + installations = cast(List[Any], raw_installations) + installed: list[str] = [] + publishable: list[str] = [] + flags_complete = True + for item in installations: + if not isinstance(item, Mapping): + flags_complete = False + continue + installation = cast(Mapping[str, Any], item) + version = cast(str, installation.get("version")) + try: + parsed = Version(version) + except (InvalidVersion, TypeError): + flags_complete = False + continue + if len(parsed.release) < 2: + flags_complete = False + continue + installed.append(version) + + flag = installation.get("publishable") + if not isinstance(flag, bool): + flags_complete = False + elif flag: + publishable.append(version) + return installed, publishable, bool(installations), flags_complete + + +def _minor(version: str) -> tuple[int, int]: + release = Version(version).release + if len(release) < 2: + raise InvalidVersion(f"Python version has no minor component: {version}") + return release[0], release[1] + + +def _pick_python_pin(versions: list[str], local_python: str) -> Optional[str]: + minors = sorted({_minor(version) for version in versions}) + if not minors: + return None + local_minor = _minor(local_python) + if local_minor in minors: + selected = local_minor + else: + newer = [version for version in minors if version > local_minor] + selected = newer[0] if newer else minors[-1] + return f"{selected[0]}.{selected[1]}" + + +def _local_publishable( + local_python: str, versions: list[str], has_installations: bool, flags_complete: bool +) -> Optional[bool]: + if any(_minor(version) == _minor(local_python) for version in versions): + return True + return False if has_installations and flags_complete else None + + +def _constraint_compatibility(constraint: Optional[str], versions: list[str]) -> str: + if constraint is None: + return "unconstrained" + if not versions: + return "unknown" + try: + specifier = SpecifierSet(constraint) + except (InvalidSpecifier, TypeError): + return "unknown" + return "compatible" if any(specifier.contains(version) for version in versions) else "incompatible" + + +def _deployment_store_paths(target: str) -> list[str]: + paths = [target] + if os.path.isdir(target): + paths.extend((fake_module_file_from_directory(target), os.path.join(target, "manifest.json"))) + return paths + + +def load_preflight_app_store(path: str) -> AppStore: + """Load safe deployment history for executor target inference.""" + module_file = fake_module_file_from_directory(path) + store_paths = [path, module_file] if os.path.isfile(path) else [module_file] + store = AppStore(module_file, autoload=False, strict_read=True) + for store_path in store_paths: + try: + candidate = AppStore(store_path, strict_read=True) + records = candidate.get_all() + if not isinstance(records, list) or any( + not isinstance(record, Mapping) + or not isinstance(record.get("server_url"), str) + or not record["server_url"] + for record in records + ): + raise TypeError("Malformed local deployment metadata.") + except _APP_STORE_READ_ERRORS: + return AppStore(store_path, autoload=False, strict_read=True) + store = candidate + if records: + return store + return store + + +def _read_app_store_record(store: Any, server_key: str) -> tuple[Optional[Mapping[str, Any]], Optional[str]]: + try: + record = store.get(server_key) + except _APP_STORE_READ_ERRORS as err: + return None, f"Could not read local deployment metadata: {err}" + if record is None: + return None, None + if not isinstance(record, Mapping): + return None, "The local deployment record is malformed." + return cast(Mapping[str, Any], record), None + + +def _read_deployment_records(executor: RSConnectExecutor, target: str) -> tuple[list[Mapping[str, Any]], Optional[str]]: + directory = os.path.isdir(target) + module_file = fake_module_file_from_directory(target) if directory else None + executor_module = fake_module_file_from_directory(executor.path) + try: + stores: list[Any] = [] + for path in _deployment_store_paths(target): + stores.append(AppStore(executor_module if path == module_file else path, strict_read=True)) + if not directory: + stores.append(AppStore(executor_module, strict_read=True)) + except _APP_STORE_READ_ERRORS as err: + return [], f"Could not read local deployment metadata: {err}" + + records: list[Mapping[str, Any]] = [] + server_key = executor.record_server_key() + for store in stores: + record, issue = _read_app_store_record(store, server_key) + if issue: + return [], issue + if record is not None: + records.append(record) + return records, None + + +def _file_deployment_records(source: Path, server_key: str) -> tuple[list[Mapping[str, Any]], Optional[str]]: + records: list[Mapping[str, Any]] = [] + try: + for app_file in (str(source), fake_module_file_from_directory(str(source))): + record, issue = _read_app_store_record(AppStore(app_file, strict_read=True), server_key) + if issue: + return [], issue + if record is not None: + records.append(record) + except _APP_STORE_READ_ERRORS as err: + return [], f"Could not check file deployment metadata: {err}" + return records, None + + +def _directory_file_deployments( + executor: RSConnectExecutor, target: str +) -> tuple[list[Mapping[str, Any]], Optional[str]]: + records: list[Mapping[str, Any]] = [] + server_key = executor.record_server_key() + try: + for source in Path(target).iterdir(): + if not source.is_file(): + continue + source_records, issue = _file_deployment_records(source, server_key) + if issue: + return [], issue + records.extend(source_records) + except _APP_STORE_READ_ERRORS as err: + return [], f"Could not check file deployment metadata: {err}" + return records, None + + +def _target_from_records(records: list[Mapping[str, Any]]) -> tuple[bool, Optional[str], Optional[str]]: + if len(records) > 1: + return True, None, "Multiple local deployment records match this target." + if not records: + return False, None, None + record_id = records[0].get("app_guid") or records[0].get("app_id") + if not isinstance(record_id, str) or not record_id: + return True, None, "The local deployment record has no content ID." + return True, record_id, None + + +def _saved_deployment_target(executor: RSConnectExecutor, target: str) -> tuple[bool, Optional[str], Optional[str]]: + records, issue = _read_deployment_records(executor, target) + if issue: + return True, None, issue + if not records and os.path.isdir(target): + records, issue = _directory_file_deployments(executor, target) + if issue: + return True, None, issue + if records: + return True, None, "This directory has a single-file deployment; pass its exact file path or --app-id." + return _target_from_records(records) + + +def _deployment_target(executor: RSConnectExecutor, target: str) -> tuple[bool, Optional[str], Optional[str]]: + if executor.new: + return False, None, None + if executor.app_id: + return True, str(executor.app_id), None + return _saved_deployment_target(executor, target) + + +def _current_python( + client: RSConnectClient, app_id: str, warnings: list[str], actions: list[str] +) -> tuple[Optional[str], bool]: + try: + content = client.get_content_by_id(app_id) + except RSConnectException as err: + warnings.append(f"Could not read existing content's current Python version: {err}") + if err.status == 404: + actions.append("The content item was not found; verify its ID or use --new to publish a new item.") + elif err.status == 403: + actions.append("Check access to the existing content before redeploying, or use --new for a separate item.") + else: + actions.append("Check the existing content ID and Connect access before redeploying.") + return None, False + if not isinstance(content, Mapping): + warnings.append("Connect returned an invalid response for the existing content.") + actions.append("Check the existing content ID and Connect access before redeploying.") + return None, False + + version = content.get("py_version") + if isinstance(version, str) and version: + return version, True + warnings.append("Connect did not report the existing content's current py_version; it is informational.") + return None, True + + +def _existing_content( + client: RSConnectClient, + exists: bool, + app_id: Optional[str], + constraint: Optional[str], + installed_versions: list[str], + warnings: list[str], + actions: list[str], +) -> tuple[dict[str, Any], bool]: + current_python: Optional[str] = None + content_readable = True + if exists and app_id: + current_python, content_readable = _current_python(client, app_id, warnings, actions) + return ( + { + "exists": exists, + "app_id": app_id, + "installed_python_version": current_python, + "server_python_versions": installed_versions, + "python_compatibility": ( + _constraint_compatibility(constraint, installed_versions) if exists else "not_applicable" + ), + }, + content_readable, + ) + + +def _write_python_pin(project: str, version: str) -> bool: + pin_path = Path(project) / ".python-version" + if os.path.lexists(pin_path): + return False + try: + with pin_path.open("x", encoding="utf-8") as pin_file: + pin_file.write(version + "\n") + except FileExistsError: + return False + except OSError as err: + raise RSConnectException(f"Could not create {pin_path}: {err}") from err + return True + + +def _recommend_python_pin( + project: str, + is_new: bool, + constraint: Optional[str], + invalid_metadata: Optional[str], + versions: list[str], + local_python: str, + fix: bool, + warnings: list[str], + actions: list[str], +) -> tuple[Optional[str], Optional[str], list[str], Optional[str]]: + if not is_new or constraint is not None or invalid_metadata: + return constraint, None, [], invalid_metadata + recommendation = _pick_python_pin(versions, local_python) + if recommendation is None: + return constraint, None, [], invalid_metadata + + changed_files: list[str] = [] + if fix: + if _write_python_pin(project, recommendation): + changed_files.append(".python-version") + constraint, invalid_metadata = _project_constraint(project, warnings) + actions.append(f"Created .python-version with Python {recommendation}.") + else: + warnings.append(".python-version already exists; it was not overwritten.") + actions.append("Review the existing .python-version before deploying.") + constraint, invalid_metadata = _project_constraint(project, warnings) + else: + actions.append(f"Add .python-version = {recommendation} to pin new content to an advertised Python version.") + + if _minor(recommendation) != _minor(local_python): + warnings.append( + f"The Python {recommendation} recommendation differs from local Python {local_python}; " + "dependency compatibility has not been tested." + ) + return constraint, recommendation, changed_files, invalid_metadata + + +def _new_content_status( + constraint: Optional[str], + publishable_versions: list[str], + flags_complete: bool, + local_publishable: Optional[bool], +) -> str: + if constraint is not None: + compatibility = _constraint_compatibility(constraint, publishable_versions) + if compatibility == "compatible": + return "ok" + if compatibility == "unknown": + return "unknown" + return "incompatible" if flags_complete else "unknown" + if not publishable_versions: + return "incompatible" if flags_complete else "unknown" + return "ok" if local_publishable else "unknown" + + +def _status( + is_new: bool, + constraint: Optional[str], + invalid_metadata: Optional[str], + installed_versions: list[str], + publishable_versions: list[str], + flags_complete: bool, + local_publishable: Optional[bool], +) -> str: + if invalid_metadata or not installed_versions: + return "unknown" + if is_new: + return _new_content_status(constraint, publishable_versions, flags_complete, local_publishable) + compatibility = _constraint_compatibility(constraint, installed_versions) + if compatibility in ("compatible", "unconstrained"): + return "ok" + return compatibility + + +def _availability_advice( + settings_error: Optional[str], + installed_versions: list[str], + publishable_versions: list[str], + has_installations: bool, + flags_complete: bool, + is_new: bool, + warnings: list[str], + actions: list[str], +) -> None: + action: Optional[str] = None + if settings_error: + warnings.append(f"Could not read server Python settings: {settings_error}") + action = "Check the server's Python installations before publishing." + elif not has_installations or not installed_versions: + warnings.append("The server did not report usable Python installations; compatibility is unknown.") + action = "Check the server's Python installations before publishing." + elif not flags_complete: + warnings.append( + "One or more Python installations lack a valid publishable flag; " + "new-content legacy permissibility is unknown." + ) + if not publishable_versions: + action = "Check the server's Python publishability settings before publishing." + elif not publishable_versions: + warnings.append("The server has no Python installations marked publishable for new content.") + action = "Mark a server Python installation publishable before publishing new content." + if is_new and action: + actions.append(action) + + +def _incompatible_advice(is_new: bool, constraint: Optional[str], warnings: list[str], actions: list[str]) -> None: + if not is_new: + warnings.append("No installed server Python version satisfies the project requirement.") + actions.append("Align the project requirement with an installed version before redeploying.") + elif constraint is None: + warnings.append("No server Python version is publishable for new content.") + actions.append("Mark a server Python installation publishable before publishing.") + else: + warnings.append("The project Python requirement matches none of the server's publishable versions.") + actions.append("Choose a Python requirement supported by the server before publishing.") + + +def _unknown_advice( + is_new: bool, + constraint: Optional[str], + recommended: Optional[str], + warnings: list[str], + actions: list[str], +) -> None: + if not is_new: + actions.append("Check the existing content ID, project Python metadata, and server runtime before redeploying.") + elif constraint is not None: + try: + SpecifierSet(constraint) + except (InvalidSpecifier, TypeError): + warnings.append(f"Python requirement {constraint!r} is not a valid PEP 440 specifier.") + actions.append("Correct the project's Python requirement metadata.") + else: + actions.append("Check the server's installed Python versions and publishability before deploying.") + elif recommended is None: + actions.append("Check the project's Python metadata and server availability before publishing.") + + +def _warn_local_python_mismatch(constraint: Optional[str], local_python: str, warnings: list[str]) -> None: + if constraint is None: + return + try: + matches_local = SpecifierSet(constraint).contains(local_python) + except (InvalidSpecifier, TypeError): + return + if not matches_local: + warnings.append( + f"Local Python {local_python} does not satisfy the project requirement {constraint}; " + "dependency compatibility has not been tested." + ) + + +def _validated_python_executor( + executor: RSConnectExecutor, +) -> Tuple[Union[RSConnectServer, SPCSConnectServer], RSConnectClient]: + server = executor.remote_server + if isinstance(server, ConnectCloudServer): + raise RSConnectException( + "Python preflight supports self-hosted Posit Connect and Connect in SPCS, not Connect Cloud." + ) + if not isinstance(server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("Python preflight supports only self-hosted Posit Connect and Connect in SPCS.") + if executor.new and executor.app_id: + raise RSConnectException("Specify either a new deploy or an app ID but not both.") + + client = executor.client + if not isinstance(client, RSConnectClient): + raise RSConnectException("Python preflight requires a self-hosted Connect client.") + return server, client + + +def _python_project_metadata( + target: str, local_python: str, warnings: list[str] +) -> tuple[str, Optional[str], Optional[str]]: + target_path = Path(target) + project_dir = str(target_path.parent) if target_path.is_file() else target + constraint, invalid_metadata = _project_constraint(project_dir, warnings) + _warn_local_python_mismatch(constraint, local_python, warnings) + return project_dir, constraint, invalid_metadata + + +def _status_advice( + status: str, + is_new: bool, + constraint: Optional[str], + invalid_metadata: Optional[str], + recommended: Optional[str], + warnings: list[str], + actions: list[str], +) -> None: + if invalid_metadata: + actions.append(f"Repair or remove the unusable Python requirement in {invalid_metadata} before deploying.") + elif status == "incompatible": + _incompatible_advice(is_new, constraint, warnings, actions) + elif status == "unknown": + _unknown_advice(is_new, constraint, recommended, warnings, actions) + + +def run_preflight(executor: RSConnectExecutor, project: str, fix: bool = False) -> dict[str, Any]: + """Check project Python metadata against a validated Connect executor.""" + require_posix("Deployment preflight") + server, client = _validated_python_executor(executor) + + warnings: list[str] = [] + actions: list[str] = [] + local_python = platform.python_version() + project_dir, constraint, invalid_metadata = _python_project_metadata(project, local_python, warnings) + is_existing, app_id, target_issue = _deployment_target(executor, project) + is_new = not is_existing + if target_issue: + warnings.append(target_issue) + actions.append( + "Pass the exact file path for single-file content, specify --app-id, " + "or use --new for a separate content item." + ) + try: + settings = client.python_settings() + settings_error = None + except RSConnectException as err: + settings = None + settings_error = str(err) + installed, publishable, has_installations, flags_complete = _server_python_versions(settings) + local_publishable = _local_publishable(local_python, publishable, has_installations, flags_complete) + constraint, recommended, changed_files, invalid_metadata = _recommend_python_pin( + project_dir, is_new, constraint, invalid_metadata, publishable, local_python, fix, warnings, actions + ) + existing_content, content_readable = _existing_content( + client, is_existing, app_id, constraint, installed, warnings, actions + ) + status = _status( + is_new, + constraint, + invalid_metadata, + installed, + publishable, + flags_complete, + local_publishable, + ) + if target_issue or not content_readable: + status = "unknown" + _availability_advice( + settings_error, installed, publishable, has_installations, flags_complete, is_new, warnings, actions + ) + _status_advice(status, is_new, constraint, invalid_metadata, recommended, warnings, actions) + + quarto_available = shutil.which("quarto") is not None + if not quarto_available: + warnings.append("Quarto CLI is unavailable; it is required only for Quarto projects.") + actions.append("Install Quarto if this project deploys Quarto content.") + return { + "status": status, + "runtime": "python", + "server": server.url, + "publishable_python_versions": publishable, + "local_python": local_python, + "local_python_publishable": local_publishable, + "recommended_python": recommended, + "python_requires": constraint, + "existing_content": existing_content, + "quarto_available": quarto_available, + "changed_files": changed_files, + "warnings": warnings, + "actions": actions, + } diff --git a/rsconnect/preflight_node.py b/rsconnect/preflight_node.py new file mode 100644 index 00000000..aecb2057 --- /dev/null +++ b/rsconnect/preflight_node.py @@ -0,0 +1,518 @@ +"""Preflight checks for Node.js deployments to Posit Connect.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from collections.abc import Mapping as MappingABC +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Tuple, cast + +from .api import ConnectCloudServer, RSConnectClient, RSConnectExecutor, RSConnectServer, SPCSConnectServer +from .environment_node import NodeEnvironment +from .exception import RSConnectException +from .preflight import _deployment_target +from .validation import require_posix + +_SEMVER_SCRIPT = """ +const fs = require("fs"); +const path = require("path"); +const npmRoot = path.dirname(path.dirname(process.argv[1])); +const semver = require(path.join(npmRoot, "node_modules", "semver")); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const hasRange = input.range !== null; +const rangeValid = !hasRange || semver.validRange(input.range) !== null; +const validVersions = input.versions.map(version => semver.valid(version) !== null); +const matches = rangeValid + ? input.versions.map((version, index) => + validVersions[index] && + (!hasRange || semver.satisfies(version, input.range))) + : []; +process.stdout.write(JSON.stringify({ range_valid: rangeValid, valid_versions: validVersions, matches })); +""" + +_NODEJS_STATUS_FLAGS = ("enabled", "licensed", "available", "usable", "configured") + + +def _project_directory(project: str) -> str: + path = Path(project) + return str(path if path.is_dir() else path.parent) + + +def _project_node_requirement(project: str, warnings: List[str], actions: List[str]) -> Tuple[Optional[str], bool]: + directory = Path(project) + package_path = directory / "package.json" + lock_path = directory / "package-lock.json" + for path in (package_path, lock_path): + if path.is_symlink(): + warnings.append(f"{path.name} is a symlink; refusing to read it.") + actions.append(f"Replace the symlinked {path.name} with a regular file before deploying Node.js content.") + return None, False + + try: + package = json.loads(package_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err: + warnings.append(f"Could not read package.json: {err}") + actions.append("Repair package.json before deploying Node.js content.") + return None, False + + if not isinstance(package, dict): + warnings.append("package.json must contain a JSON object.") + actions.append("Repair package.json before deploying Node.js content.") + return None, False + package = cast(Dict[str, Any], package) + + engines = package.get("engines") + if "engines" in package and not isinstance(engines, dict): + warnings.append("package.json engines must be an object.") + actions.append("Repair package.json engines metadata before deploying.") + return None, False + + if not isinstance(engines, dict): + return None, True + engines = cast(Dict[str, Any], engines) + if "node" not in engines: + return None, True + requirement = engines["node"] + if not isinstance(requirement, str): + warnings.append("package.json engines.node must be a string.") + actions.append("Repair package.json engines.node before deploying.") + return None, False + return requirement, True + + +def _local_node_version(project: str, node: Optional[str], warnings: List[str], actions: List[str]) -> Optional[str]: + try: + return NodeEnvironment.create(project, node_executable=node).node_version + except (RSConnectException, OSError, UnicodeDecodeError) as err: + warnings.append(f"Could not inspect local Node.js/npm prerequisites: {err}") + actions.append("Install Node.js and npm, and ensure package.json and package-lock.json are available.") + return None + + +def _is_npm_installation(npm_cli: Path) -> bool: + npm_root = npm_cli.parent.parent + if npm_cli.name != "npm-cli.js" or npm_root.name != "npm": + return False + try: + npm_metadata = json.loads((npm_root / "package.json").read_text(encoding="utf-8")) + semver_metadata = json.loads( + (npm_root / "node_modules" / "semver" / "package.json").read_text(encoding="utf-8") + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return False + if not isinstance(npm_metadata, dict) or not isinstance(semver_metadata, dict): + return False + npm_package = cast(Dict[str, Any], npm_metadata) + semver_package = cast(Dict[str, Any], semver_metadata) + return npm_package.get("name") == "npm" and semver_package.get("name") == "semver" + + +def _npm_cli_path() -> Optional[str]: + npm = shutil.which("npm") + if not npm: + return None + npm_cli = Path(os.path.realpath(npm)) + return str(npm_cli) if npm_cli.is_file() and _is_npm_installation(npm_cli) else None + + +def _parse_node_evaluation(stdout: str, version_count: int) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + try: + evaluation = json.loads(stdout) + except json.JSONDecodeError as err: + return None, f"npm's semver evaluator returned invalid output: {err}" + if not isinstance(evaluation, dict): + return None, "npm's semver evaluator returned incomplete output." + evaluation = cast(Dict[str, Any], evaluation) + if ( + not isinstance(evaluation.get("range_valid"), bool) + or not isinstance(evaluation.get("valid_versions"), list) + or not isinstance(evaluation.get("matches"), list) + ): + return None, "npm's semver evaluator returned incomplete output." + valid_versions = cast(List[Any], evaluation["valid_versions"]) + matches = cast(List[Any], evaluation["matches"]) + if not _valid_node_results(evaluation["range_valid"], valid_versions, matches, version_count): + return None, "npm's semver evaluator returned incomplete version results." + return evaluation, None + + +def _valid_node_results(range_valid: bool, valid_versions: List[Any], matches: List[Any], version_count: int) -> bool: + if len(valid_versions) != version_count: + return False + if any(not isinstance(value, bool) for value in valid_versions + matches): + return False + if range_valid: + return len(matches) == version_count + return not matches + + +def _evaluate_node_range( + node: str, requirement: Optional[str], versions: List[str] +) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + npm_cli = _npm_cli_path() + if npm_cli is None: + return None, "Could not locate npm's local semver module." + + try: + result = subprocess.run( + [node, "-e", _SEMVER_SCRIPT, npm_cli], + input=json.dumps({"range": requirement, "versions": versions}), + capture_output=True, + text=True, + timeout=10, + check=False, + shell=False, + ) + except (OSError, UnicodeError, subprocess.TimeoutExpired) as err: + return None, f"Could not run npm's semver evaluator: {err}" + + if result.returncode != 0: + detail = result.stderr.strip()[:400] + return None, f"Could not load npm's semver module: {detail or result.returncode}" + + return _parse_node_evaluation(result.stdout, len(versions)) + + +def _server_status(value: Any, warnings: List[str], actions: List[str]) -> Tuple[Optional[Dict[str, Any]], bool]: + if not isinstance(value, MappingABC): + warnings.append("Connect did not return Node.js runtime status flags; compatibility is unknown.") + actions.append("Check whether this Connect server exposes Node.js runtime status flags.") + return None, False + status = dict(cast(Mapping[str, Any], value)) + if not any(field in status for field in _NODEJS_STATUS_FLAGS): + warnings.append("Connect did not return Node.js runtime status flags; compatibility is unknown.") + actions.append("Check whether this Connect server exposes Node.js runtime status flags.") + return status, False + complete = True + for field in _NODEJS_STATUS_FLAGS: + if field not in status: + continue + if not isinstance(status[field], bool): + warnings.append(f"Connect returned an invalid Node.js {field} status flag.") + complete = False + elif status[field] is False: + warnings.append(f"Connect reports Node.js is not {field}.") + return status, complete + + +def _server_installations(value: Any, warnings: List[str]) -> Tuple[List[Tuple[str, Optional[bool]]], bool, bool]: + if not isinstance(value, list): + warnings.append("Connect did not return Node.js installations; compatibility is unknown.") + return [], False, False + installations: List[Tuple[str, Optional[bool]]] = [] + metadata_complete = True + publishability_complete = True + for raw_item in cast(List[Any], value): + if not isinstance(raw_item, MappingABC): + metadata_complete = False + publishability_complete = False + warnings.append("Connect returned an invalid Node.js installation record.") + continue + item = cast(Mapping[str, Any], raw_item) + version = item.get("version") + if not isinstance(version, str) or not version: + metadata_complete = False + publishability_complete = False + warnings.append("Connect returned a Node.js installation without a version.") + continue + publishable = item.get("publishable") + if not isinstance(publishable, bool): + publishability_complete = False + warnings.append(f"Connect did not report whether Node.js {version} is publishable.") + publishable = None + installations.append((version, publishable)) + return installations, metadata_complete, publishability_complete + + +def _server_nodejs_info(settings: Any, warnings: List[str], actions: List[str]) -> Dict[str, Any]: + info: Dict[str, Any] = { + "enabled": None, + "status": None, + "installations": [], + "status_complete": False, + "runtime_complete": False, + "publishability_complete": False, + } + if not isinstance(settings, MappingABC): + warnings.append("Connect did not return Node.js settings; compatibility is unknown.") + actions.append("Check whether this Connect server exposes Node.js runtime settings.") + return info + + settings = cast(Mapping[str, Any], settings) + enabled = settings.get("enabled") + if not isinstance(enabled, bool): + warnings.append("Connect did not report whether Node.js is enabled; compatibility is unknown.") + actions.append("Check whether Node.js is enabled in the Connect server settings.") + status, status_complete = _server_status(settings.get("status"), warnings, actions) + installations, installations_complete, publishability_complete = _server_installations( + settings.get("installations"), warnings + ) + info["enabled"] = enabled if isinstance(enabled, bool) else None + info["status"] = status + info["installations"] = installations + info["status_complete"] = status_complete + info["runtime_complete"] = isinstance(enabled, bool) and installations_complete + info["publishability_complete"] = publishability_complete + return info + + +def _nodejs_status_failures(info: Dict[str, Any]) -> List[str]: + status = info["status"] + if not isinstance(status, MappingABC): + return [] + status = cast(Mapping[str, Any], status) + return [field for field in _NODEJS_STATUS_FLAGS if status.get(field) is False] + + +def _eligible_installations(installations: List[Tuple[str, Optional[bool]]], is_new: bool) -> List[int]: + return [index for index, (_, publishable) in enumerate(installations) if not is_new or publishable is True] + + +def _valid_node_evaluation(evaluation: Optional[Dict[str, Any]]) -> bool: + return evaluation is not None and evaluation["range_valid"] + + +def _has_matching_installation(indices: List[int], evaluation: Dict[str, Any]) -> bool: + valid_versions = evaluation["valid_versions"] + matches = evaluation["matches"] + return any(valid_versions[index] and matches[index] for index in indices) + + +def _unmatched_server_compatibility( + indices: List[int], is_new: bool, info: Dict[str, Any], evaluation: Dict[str, Any] +) -> str: + versions_complete = all(evaluation["valid_versions"][index] for index in indices) + flags_complete = info["publishability_complete"] or not is_new + return "incompatible" if versions_complete and flags_complete else "unknown" + + +def _server_compatibility(info: Dict[str, Any], is_new: bool, evaluation: Optional[Dict[str, Any]]) -> str: + if info["enabled"] is False or _nodejs_status_failures(info): + return "incompatible" + if not info["runtime_complete"]: + return "unknown" + + installations = info["installations"] + indices = _eligible_installations(installations, is_new) + flags_complete = info["publishability_complete"] or not is_new + if not indices: + return "incompatible" if flags_complete else "unknown" + if not _valid_node_evaluation(evaluation): + return "unknown" + if _has_matching_installation(indices, cast(Dict[str, Any], evaluation)): + return "compatible" if info["status_complete"] else "unknown" + return _unmatched_server_compatibility(indices, is_new, info, cast(Dict[str, Any], evaluation)) + + +def _existing_content_version( + client: RSConnectClient, app_id: str, warnings: List[str], actions: List[str] +) -> Tuple[Optional[str], bool]: + try: + content = client.get_content_by_id(app_id) + except RSConnectException as err: + warnings.append(f"Could not read existing content's Node.js version: {err}") + if err.status == 404: + actions.append("Use --new to publish as new content, or verify the existing content ID.") + else: + actions.append("Verify the existing content ID and your permission to read it.") + return None, False + if not isinstance(content, MappingABC): + warnings.append("Connect returned invalid metadata for existing content.") + actions.append("Check the existing content ID and retry preflight.") + return None, False + value = cast(Mapping[str, Any], content).get("node_version") + return (value if isinstance(value, str) else None), True + + +def _existing_content( + client: RSConnectClient, + exists: bool, + app_id: Optional[str], + compatibility: str, + warnings: List[str], + actions: List[str], + target_issue: Optional[str], +) -> Tuple[Dict[str, Any], bool]: + node_version = None + readable = not exists + if exists and app_id: + node_version, readable = _existing_content_version(client, app_id, warnings, actions) + elif exists and not target_issue: + warnings.append("The existing deployment has no content ID to inspect.") + actions.append("Use --new or provide a valid content ID before checking a redeployment.") + return { + "exists": exists, + "app_id": app_id, + "node_version": node_version, + "compatibility": compatibility if readable and exists else ("unknown" if exists else "not_applicable"), + }, readable + + +def _validated_executor(executor: RSConnectExecutor) -> Tuple[Any, RSConnectClient]: + server = executor.remote_server + if isinstance(server, ConnectCloudServer) or not isinstance(server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("Node.js preflight supports self-hosted Posit Connect and Connect in SPCS.") + if executor.new and executor.app_id: + raise RSConnectException("Specify either a new deploy or an app ID but not both.") + client = executor.client + if not isinstance(client, RSConnectClient): + raise RSConnectException("Node.js preflight requires a self-hosted Connect client.") + return server, client + + +def _read_server_nodejs_info(client: RSConnectClient, warnings: List[str], actions: List[str]) -> Dict[str, Any]: + try: + settings = client.nodejs_settings() + except RSConnectException as err: + warnings.append(f"Could not read server Node.js settings: {err}") + settings = None + return _server_nodejs_info(settings, warnings, actions) + + +def _evaluate_project_range( + node: str, + requirement: Optional[str], + versions: List[str], + project_metadata_valid: bool, + warnings: List[str], + actions: List[str], +) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + if not project_metadata_valid: + return None, None + evaluation, error = _evaluate_node_range(node, requirement, versions) + if error: + warnings.append(error) + actions.append("Verify the local Node.js and npm installation before checking engines.node.") + elif evaluation is not None and not evaluation["range_valid"]: + warnings.append(f"package.json engines.node {requirement!r} is not a valid npm semver range.") + actions.append("Repair package.json engines.node before deploying.") + return evaluation, error + + +def _project_details( + project: str, node: Optional[str], warnings: List[str], actions: List[str] +) -> Tuple[str, Optional[str], bool, Optional[str]]: + directory = _project_directory(project) + requirement, metadata_valid = _project_node_requirement(directory, warnings, actions) + local_node = _local_node_version(directory, node, warnings, actions) if metadata_valid else None + return directory, requirement, metadata_valid, local_node + + +def _local_checks_complete( + metadata_valid: bool, + local_node: Optional[str], + evaluation: Optional[Dict[str, Any]], + evaluation_error: Optional[str], +) -> bool: + return ( + metadata_valid + and local_node is not None + and evaluation_error is None + and evaluation is not None + and evaluation["range_valid"] + ) + + +def _add_status_failure_actions(info: Dict[str, Any], actions: List[str]) -> List[str]: + failures = _nodejs_status_failures(info) + if info["enabled"] is False: + actions.append("Enable Node.js on the Connect server before publishing.") + if "enabled" in failures: + actions.append("Enable Node.js in the Connect server settings before publishing.") + if "configured" in failures: + actions.append("Configure Node.js on the Connect server before publishing.") + if "licensed" in failures: + actions.append("Check or update the Connect license to include Node.js support.") + if "available" in failures or "usable" in failures: + actions.append("Make a supported Node.js runtime available and usable on Connect before publishing.") + return failures + + +def _add_server_advice( + info: Dict[str, Any], + compatibility: str, + exists: bool, + warnings: List[str], + actions: List[str], +) -> None: + failures = _add_status_failure_actions(info, actions) + if info["enabled"] is False or failures or compatibility != "incompatible": + return + if exists: + warnings.append("No installed server Node.js version satisfies package.json engines.node.") + actions.append("Align package.json engines.node with an installed server version before redeploying.") + else: + warnings.append("No publishable server Node.js version satisfies this new content.") + actions.append("Choose a supported engines.node range or mark a server version publishable.") + + +def _reported_status( + compatibility: str, + enabled: Optional[bool], + local_checks_complete: bool, + target_resolved: bool, + content_readable: bool, +) -> str: + if not target_resolved or not content_readable: + return "unknown" + if enabled is False or compatibility == "incompatible": + return "incompatible" + if not local_checks_complete: + return "unknown" + return "ok" if compatibility == "compatible" else compatibility + + +def run_node_preflight(executor: RSConnectExecutor, project: str, node: Optional[str] = None) -> Dict[str, Any]: + """Check Node.js project metadata against a validated Connect executor.""" + require_posix("Deployment preflight") + server, client = _validated_executor(executor) + warnings: List[str] = [] + actions: List[str] = [] + target = project + _, requirement, project_metadata_valid, local_node = _project_details(project, node, warnings, actions) + info = _read_server_nodejs_info(client, warnings, actions) + installations = info["installations"] + versions = [version for version, _ in installations] + evaluation, evaluation_error = _evaluate_project_range( + node or "node", requirement, versions, project_metadata_valid, warnings, actions + ) + + exists, app_id, target_issue = _deployment_target(executor, target) + if target_issue: + warnings.append(target_issue) + actions.append("Resolve the local deployment record with --app-id or use --new for a separate content item.") + compatibility = "unknown" if target_issue else _server_compatibility(info, not exists, evaluation) + existing_content, content_readable = _existing_content( + client, exists, app_id, compatibility, warnings, actions, target_issue + ) + if not content_readable: + compatibility = "unknown" + existing_content["compatibility"] = "unknown" + status = _reported_status( + compatibility, + info["enabled"], + _local_checks_complete(project_metadata_valid, local_node, evaluation, evaluation_error), + target_issue is None, + content_readable, + ) + _add_server_advice(info, compatibility, exists, warnings, actions) + server_node_versions = [version for version, _ in installations] + publishable_versions = [version for version, publishable in installations if publishable is True] + return { + "status": status, + "runtime": "nodejs", + "server": server.url, + "local_node": local_node, + "node_requires": requirement, + "server_node_versions": server_node_versions, + "publishable_node_versions": publishable_versions, + "nodejs_enabled": info["enabled"], + "nodejs_status": info["status"], + "existing_content": existing_content, + "changed_files": [], + "warnings": warnings, + "actions": actions, + } diff --git a/rsconnect/validation.py b/rsconnect/validation.py index e07f4a62..6cac906b 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -9,6 +9,11 @@ from rsconnect.exception import RSConnectException +def require_posix(feature: str) -> None: + if os.name != "posix": + raise RSConnectException(f"{feature} is supported only on POSIX systems (Linux and macOS).") + + def get_parameter_source_name_from_ctx( var_or_param_name: str, ctx: Optional[click.Context], diff --git a/tests/test_agent_cli.py b/tests/test_agent_cli.py new file mode 100644 index 00000000..1ed9e3d6 --- /dev/null +++ b/tests/test_agent_cli.py @@ -0,0 +1,378 @@ +"""Exercise the CLI contracts used by short-lived agent shells.""" + +import inspect +import json +import logging +import os +from unittest.mock import Mock, patch + +import click +import pytest +from click.testing import CliRunner + +from rsconnect import main +from rsconnect.main import cli +from rsconnect.api import RSConnectClient, RSConnectServer +from rsconnect.exception import RSConnectException + + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Agent login and preflight require POSIX.") + + +def cli_runner(): + options = {"mix_stderr": False} if "mix_stderr" in inspect.signature(CliRunner).parameters else {} + return CliRunner(**options) + + +@pytest.fixture(autouse=True) +def isolated_settings(monkeypatch, tmp_path): + for variable in ( + "CONNECT_SERVER", + "CONNECT_API_KEY", + "CONNECT_IDENTITY_TOKEN", + "CONNECT_IDENTITY_TOKEN_FILE", + "CONNECT_CLOUD_ACCOUNT", + "CONNECT_CLOUD_CLIENT_ID", + "CONNECT_CLOUD_CLIENT_SECRET", + "SHINYAPPS_ACCOUNT", + ): + monkeypatch.delenv(variable, raising=False) + for variable in ("HOME", "XDG_CONFIG_HOME", "APPDATA"): + monkeypatch.setenv(variable, str(tmp_path)) + yield + from rsconnect.actions import set_verbosity + + set_verbosity(0) + + +def test_connect_start_json_and_default_policy(): + expected = {"status": "pending", "verification_uri": "https://example.com/verify", "user_code": "ABCD"} + with patch("rsconnect.device_login.start_connect_login", return_value=expected) as start: + result = cli_runner().invoke( + cli, + ["login", "https://example.com", "--no-wait", "--no-set-default", "--client-id", "public-client"], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == expected + start.assert_called_once_with( + "https://example.com", + "example.com", + insecure=False, + ca_data=None, + set_default=False, + client_id="public-client", + ) + + +def test_login_callback_keeps_legacy_signature_and_saves_credentials(tmp_path, monkeypatch): + store = main.ServerStore(str(tmp_path)) + monkeypatch.setattr(main, "server_store", store) + metadata = {"authorization_endpoint": "https://example.test/authorize"} + tokens = {"access_token": "access", "refresh_token": "refresh"} + + with patch("rsconnect.oauth.discover_oauth_metadata", return_value=metadata) as discover: + with patch("rsconnect.oauth.register_client", return_value="client") as register: + with patch("rsconnect.oauth.login_with_browser", return_value=tokens) as login: + with patch("rsconnect.oauth.keyring_store_token", return_value=False) as save_token: + with click.Context(main.login, info_name="login"): + main.login.callback( + None, "https://example.test", "legacy", False, None, None, None, False, None, False, 0 + ) + + discover.assert_called_once_with("https://example.test", False, None) + register.assert_called_once_with(metadata, "https://example.test", False, None) + login.assert_called_once_with("https://example.test", "client", metadata, False, None) + save_token.assert_called_once_with("https://example.test", "access", "refresh") + assert store.get_by_name("legacy")["oauth_client_id"] == "client" + assert json.loads((tmp_path / "servers.json").read_text())["legacy"]["oauth_access_token"] == "access" + + +def test_add_callback_keeps_legacy_signature_and_saves_credentials(tmp_path, monkeypatch): + store = main.ServerStore(str(tmp_path)) + monkeypatch.setattr(main, "server_store", store) + server = RSConnectServer("https://connect.example.test", "api-key") + + with patch("rsconnect.main._test_server_and_api", return_value=(server, None)) as check: + with click.Context(main.add, info_name="add"): + main.add.callback( + "legacy", + "https://connect.example.test", + "api-key", + None, + False, + None, + None, + None, + None, + None, + None, + False, + True, + 0, + ) + + check.assert_called_once_with("https://connect.example.test", "api-key", False, None) + assert store.get_by_name("legacy")["api_key"] == "api-key" + saved = json.loads((tmp_path / "servers.json").read_text())["legacy"] + assert saved["url"] == "https://connect.example.test" + assert saved["default"] is True + + +@pytest.mark.parametrize( + "command,kind", + [ + (["login"], "connect"), + (["add", "--connect-cloud"], "cloud"), + (["server", "add", "--connect-cloud"], "cloud"), + ], +) +def test_finish_selected_login_and_pending_json(command, kind): + expected = {"status": "pending", "name": "target"} + with patch("rsconnect.device_login.finish_login", return_value=expected) as finish: + result = cli_runner().invoke(cli, command + ["--name", "target", "--finish", "--timeout", "7"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == expected + finish.assert_called_once_with(kind, "target", 7) + + +@pytest.mark.parametrize( + "arguments,message", + [ + (["login", "--finish"], "--finish requires --name"), + (["login", "--finish", "--no-wait"], "only one"), + (["login", "https://one.example", "-s", "https://two.example", "--no-wait"], "only one"), + (["login", "--no-wait"], "specify the server"), + (["login", "https://example.com", "--identity-token", "synthetic", "--no-wait"], "identity token"), + (["login", "https://example.com", "--timeout", "7"], "--timeout requires --finish"), + (["login", "https://example.com", "--timeout", "120"], "--timeout requires --finish"), + (["login", "https://example.com", "--no-wait", "--timeout", "7"], "--timeout requires --finish"), + (["login", "https://example.com", "-n", "target", "--finish"], "saved login options"), + (["login", "-n", "target", "--finish", "--timeout", "0"], "Invalid value"), + (["login", "-n", "target", "--finish", "--timeout", "-1"], "Invalid value"), + (["add", "-n", "target", "--no-wait"], "require --connect-cloud"), + (["add", "--connect-cloud", "--no-wait"], "requires --account"), + (["add", "--connect-cloud", "-A", "team", "--client-id", "id", "--no-wait"], "service account"), + (["add", "--connect-cloud", "-A", "team", "-s", "https://other.example", "--no-wait"], "cannot be combined"), + (["add", "--connect-cloud", "-A", "team", "--insecure", "--no-wait"], "Posit Connect options"), + (["add", "--connect-cloud", "-A", "team", "--api-key", "synthetic", "--no-wait"], "Posit Connect options"), + (["add", "--connect-cloud", "-A", "team", "--token", "synthetic", "--no-wait"], "shinyapps.io options"), + (["add", "--connect-cloud", "-n", "cloud", "--finish", "--account", "team"], "saved login options"), + (["add", "--connect-cloud", "-n", "cloud", "--finish", "--api-key", "synthetic"], "saved login options"), + ], +) +def test_device_login_option_errors(arguments, message): + result = cli_runner().invoke(cli, arguments) + assert result.exit_code == (2 if "Invalid value" in message else 1) + assert result.stdout == "" + assert message in result.stderr + + +def test_cloud_start_does_not_take_an_ambient_connect_server(monkeypatch): + monkeypatch.setenv("CONNECT_SERVER", "https://unrelated.example.com") + expected = {"status": "pending", "account": "team"} + with patch("rsconnect.device_login.start_cloud_login", return_value=expected) as start: + result = cli_runner().invoke(cli, ["add", "--connect-cloud", "-A", "team", "-n", "cloud", "--no-wait"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == expected + assert start.call_args.args == ("team", "cloud") + assert start.call_args.kwargs["set_default"] is False + assert "unrelated" not in start.call_args.kwargs["url"] + + +def test_cloud_start_does_not_take_an_ambient_shinyapps_account(monkeypatch): + monkeypatch.setenv("SHINYAPPS_ACCOUNT", "another-team") + result = cli_runner().invoke(cli, ["add", "--connect-cloud", "--no-wait"]) + assert result.exit_code == 1 + assert result.stdout == "" + assert "requires --account" in result.stderr + + +@pytest.mark.parametrize("status,exit_code", [("ok", 0), ("incompatible", 3), ("unknown", 0)]) +def test_preflight_json_and_exit_status(tmp_path, status, exit_code): + expected = {"status": status, "changed_files": []} + executor = Mock() + executor.validate_server.return_value = executor + with patch("rsconnect.main.RSConnectExecutor", return_value=executor) as create: + with patch("rsconnect.preflight.run_preflight", return_value=expected) as check: + result = cli_runner().invoke(cli, ["preflight", "-n", "target", str(tmp_path), "--fix", "--new"]) + assert result.exit_code == exit_code, result.output + assert json.loads(result.stdout) == expected + check.assert_called_once_with(executor, str(tmp_path), True) + assert create.call_args.kwargs["path"] == str(tmp_path) + assert create.call_args.kwargs["new"] is True + + +def test_preflight_new_and_explicit_content_are_exclusive(): + result = cli_runner().invoke(cli, ["preflight", "--new", "--app-id", "synthetic"]) + assert result.exit_code == 2 + assert result.stdout == "" + assert "only one" in result.stderr + + +def test_node_preflight_dispatch_and_incompatible_exit(tmp_path): + expected = {"status": "incompatible", "runtime": "nodejs", "changed_files": []} + executor = Mock() + executor.validate_server.return_value = executor + executable = tmp_path / "node" + executable.write_text("") + with patch("rsconnect.main.RSConnectExecutor", return_value=executor): + with patch("rsconnect.preflight_node.run_node_preflight", return_value=expected) as check: + result = cli_runner().invoke( + cli, ["preflight", "-n", "target", str(tmp_path), "--runtime", "nodejs", "--node", str(executable)] + ) + assert result.exit_code == 3, result.output + assert json.loads(result.stdout) == expected + check.assert_called_once_with(executor, str(tmp_path), str(executable)) + + +@pytest.mark.parametrize( + "arguments,message", + [ + (["--runtime", "nodejs", "--fix"], "--fix applies only to Python"), + (["--runtime", "unsupported"], "Invalid value"), + ], +) +def test_node_preflight_invalid_options(arguments, message): + result = cli_runner().invoke(cli, ["preflight"] + arguments) + assert result.exit_code == 2 + assert result.stdout == "" + assert message in result.stderr + + +def test_node_executable_requires_node_runtime(tmp_path): + executable = tmp_path / "node" + executable.write_text("") + result = cli_runner().invoke(cli, ["preflight", "--node", str(executable)]) + assert result.exit_code == 2 + assert result.stdout == "" + assert "--node requires --runtime nodejs" in result.stderr + + +@pytest.mark.parametrize( + "command,options", + [ + (["login"], ["https://connect.example.test"]), + (["login"], ["--server", "https://connect.example.test"]), + (["login"], ["--identity-token", "synthetic"]), + (["login"], ["--identity-token-file", "{file}"]), + (["login"], ["--client-id", "synthetic"]), + (["login"], ["--insecure"]), + (["login"], ["--cacert", "{file}"]), + (["login"], ["--no-set-default"]), + (["login"], ["--use-device-code"]), + (["add", "--connect-cloud"], ["--account", "team"]), + (["add", "--connect-cloud"], ["--client-id", "synthetic"]), + (["add", "--connect-cloud"], ["--client-secret", "synthetic"]), + (["add", "--connect-cloud"], ["--set-default"]), + (["add", "--connect-cloud"], ["--api-key", "synthetic"]), + (["add", "--connect-cloud"], ["--snowflake-connection-name", "synthetic"]), + (["add", "--connect-cloud"], ["--token", "synthetic"]), + (["add", "--connect-cloud"], ["--secret", "synthetic"]), + ], +) +def test_finish_rejects_each_explicit_start_option(tmp_path, command, options): + credential_file = tmp_path / "credential" + credential_file.write_text("synthetic") + options = [str(credential_file) if option == "{file}" else option for option in options] + with patch("rsconnect.device_login.finish_login") as finish: + result = cli_runner().invoke(cli, command + ["--name", "target", "--finish"] + options) + assert result.exit_code == 1 + assert result.stdout == "" + assert "saved login options" in result.stderr + finish.assert_not_called() + + +@pytest.mark.parametrize("command", [["login"], ["add", "--connect-cloud"], ["server", "add", "--connect-cloud"]]) +def test_finish_ignores_ambient_start_options(monkeypatch, command): + monkeypatch.setenv("CONNECT_SERVER", "https://unrelated.example.test") + monkeypatch.setenv("CONNECT_INSECURE", "true") + expected = {"status": "pending", "name": "target"} + with patch("rsconnect.device_login.finish_login", return_value=expected) as finish: + result = cli_runner().invoke(cli, command + ["--name", "target", "--finish"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == expected + finish.assert_called_once() + + +def test_preflight_operational_failure_has_json_and_distinct_exit(tmp_path): + with patch("rsconnect.main.RSConnectExecutor", side_effect=RSConnectException("Authentication failed.")): + result = cli_runner().invoke(cli, ["preflight", "-n", "target", str(tmp_path)]) + assert result.exit_code == 1 + report = json.loads(result.stdout) + assert report["status"] == "error" + assert report["runtime"] == "python" + assert report["error"] == "Authentication failed." + assert report["actions"] + assert report["changed_files"] == [] + assert result.stderr == "" + + +@pytest.mark.parametrize( + "arguments", + [["--new", "--app-id", "synthetic"], ["--runtime", "nodejs", "--fix"], ["--node", "{file}"]], +) +def test_preflight_option_conflicts_are_usage_errors(tmp_path, arguments): + node = tmp_path / "node" + node.write_text("") + arguments = [str(node) if argument == "{file}" else argument for argument in arguments] + result = cli_runner().invoke(cli, ["preflight", str(tmp_path)] + arguments) + assert result.exit_code == 2 + assert result.stdout == "" + assert "Error:" in result.stderr + + +def test_node_preflight_accepts_file_deployment_target(tmp_path): + target = tmp_path / "manifest.json" + target.write_text("{}") + expected = {"status": "unknown", "runtime": "nodejs", "actions": ["Verify local prerequisites."]} + executor = Mock() + executor.validate_server.return_value = executor + with patch("rsconnect.main.RSConnectExecutor", return_value=executor): + with patch("rsconnect.preflight_node.run_node_preflight", return_value=expected) as check: + result = cli_runner().invoke(cli, ["preflight", str(target), "--runtime", "nodejs"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == expected + check.assert_called_once_with(executor, str(target), None) + + +@pytest.mark.parametrize("verbose", ["-v", "-vv"]) +def test_device_json_commands_honor_verbosity(caplog, verbose): + expected = {"status": "pending", "name": "target"} + with caplog.at_level(logging.DEBUG, logger="rsconnect"): + with patch("rsconnect.device_login.finish_login", return_value=expected): + result = cli_runner().invoke(cli, ["login", "-n", "target", "--finish", verbose]) + assert result.exit_code == 0 + assert json.loads(result.stdout) == expected + assert "Finishing device authentication." in caplog.text + + +@pytest.mark.parametrize("metadata,status,exit_code", [("3.12\n", "ok", 0), ("3.9\n", "incompatible", 3)]) +def test_python_preflight_real_report_through_cli(tmp_path, monkeypatch, metadata, status, exit_code): + from rsconnect.metadata import AppStore + + (tmp_path / ".python-version").write_text(metadata) + monkeypatch.setattr("rsconnect.preflight.platform.python_version", lambda: "3.12.2") + client = RSConnectClient(RSConnectServer("https://connect.example.test", "synthetic")) + monkeypatch.setattr( + client, "python_settings", lambda: {"installations": [{"version": "3.12.8", "publishable": True}]} + ) + executor = Mock() + executor.remote_server = client._server + executor.client = client + executor.app_store = AppStore(str(tmp_path / "app.py")) + executor.path = str(tmp_path) + executor.app_id = None + executor.new = True + executor.validate_server.return_value = executor + with patch("rsconnect.main.RSConnectExecutor", return_value=executor): + result = cli_runner().invoke(cli, ["preflight", str(tmp_path), "--new"]) + assert result.exit_code == exit_code, result.output + report = json.loads(result.stdout) + assert report["status"] == status + assert report["runtime"] == "python" + assert report["python_requires"] == ("~=3.12.0" if status == "ok" else "~=3.9.0") + assert report["publishable_python_versions"] == ["3.12.8"] + assert report["local_python"] == "3.12.2" + assert report["existing_content"]["exists"] is False + assert report["changed_files"] == [] + assert (tmp_path / ".python-version").read_text() == metadata diff --git a/tests/test_agent_cli_e2e.py b/tests/test_agent_cli_e2e.py new file mode 100644 index 00000000..7aa8add5 --- /dev/null +++ b/tests/test_agent_cli_e2e.py @@ -0,0 +1,1868 @@ +"""End-to-end CLI tests for resumable OAuth login and runtime preflight.""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + +from rsconnect.metadata import AppStore + + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Agent login and preflight require POSIX.") + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_CONTENT_GUID = "11111111-1111-4111-8111-111111111111" +_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" + +_CLI_BOOTSTRAP = """ +import os +import runpy + +cloud_base = os.environ.get("RSCONNECT_E2E_CLOUD_BASE_URL") +if cloud_base: + from rsconnect import connect_cloud + + connect_cloud._ENVIRONMENTS["production"] = connect_cloud.ConnectCloudUrls( + api=cloud_base + "/v1", + ui=cloud_base, + auth=cloud_base, + logs=cloud_base + "/v1", + ) + +token_read_delay = os.environ.get("RSCONNECT_E2E_TOKEN_READ_DELAY") +if token_read_delay: + import http.client + import time + + original_read = http.client.HTTPResponse.read + + def read_completed_token(self, *args, **kwargs): + body = original_read(self, *args, **kwargs) + if self.status == 200 and b'"access_token"' in body: + time.sleep(float(token_read_delay)) + return body + + http.client.HTTPResponse.read = read_completed_token + +runpy.run_module("rsconnect.main", run_name="__main__") +""" + + +class _Handler(BaseHTTPRequestHandler): + def _record(self) -> dict[str, object]: + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + headers = {key.lower(): value for key, value in self.headers.items()} + record: dict[str, object] = { + "method": self.command, + "path": self.path, + "headers": headers, + "body": body, + "received_at": time.monotonic(), + } + with self.server.lock: # type: ignore[attr-defined] + self.server.requests.append(record) # type: ignore[attr-defined] + return record + + def _json( + self, status: int, data: object, reason: str | None = None, extra_headers: dict[str, str] | None = None + ) -> None: + body = data if isinstance(data, bytes) else json.dumps(data).encode("utf-8") + phase = self.server.slow_response_phase # type: ignore[attr-defined] + if phase and urlsplit(self.path).path in self.server.slow_response_paths: # type: ignore[attr-defined] + self._slow_json(status, body, phase) + return + self.send_response(status, reason) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + for key, value in (extra_headers or {}).items(): + self.send_header(key, value) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def _slow_json(self, status: int, body: bytes, phase: str) -> None: + headers = ( + f"HTTP/1.0 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\n\r\n" + ).encode("ascii") + try: + if phase == "headers": + self._drip(headers) + self.wfile.write(body) + else: + self.wfile.write(headers) + self._drip(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def _drip(self, data: bytes) -> None: + for byte in data: + self.wfile.write(bytes([byte])) + self.wfile.flush() + time.sleep(0.06) + + def do_GET(self) -> None: + record = self._record() + path = urlsplit(str(record["path"])).path + if path == "/.well-known/oauth-authorization-server": + self._json( + 200, + { + "device_authorization_endpoint": self.server.base_url + "/oauth/device/authorize", # type: ignore[attr-defined] + "token_endpoint": self.server.base_url + "/oauth/token", # type: ignore[attr-defined] + "registration_endpoint": self.server.base_url + "/oauth/register", # type: ignore[attr-defined] + }, + ) + elif path == "/__api__/server_settings": + status = self.server.server_settings_status # type: ignore[attr-defined] + if status is None: + self._json(200, {"version": "2025.03.0"}) + else: + self._json(status, {"error": "server temporarily unavailable"}) + elif path == "/__api__/v1/user": + if self.server.reject_api_key: # type: ignore[attr-defined] + self._json( + 401, + { + "code": 30, + "error": "unauthorized", + "error_description": "preflight-auth-secret", + }, + ) + else: + self._json(200, {"guid": "local-user"}) + elif path == "/__api__/v1/server_settings/python": + self._json(200, {"api_enabled": True, "installations": self.server.python_installations}) # type: ignore[attr-defined] + elif path == "/__api__/v1/server_settings/nodejs": + self._json(200, self.server.nodejs_settings) # type: ignore[attr-defined] + elif path.startswith("/__api__/v1/content/"): + content_id = path.rsplit("/", 1)[-1] + status = self.server.content_statuses.get(content_id) # type: ignore[attr-defined] + if status is not None and status != 200: + self._json(status, {"error": "content lookup failed"}) + else: + self._json(200, {"py_version": "3.12.8", "node_version": "22.22.2"}) + elif path == "/v1/accounts": + self._cloud_accounts(record) + else: + self._json(404, {"error": "not found"}) + + def _cloud_accounts(self, record: dict[str, object]) -> None: + with self.server.lock: # type: ignore[attr-defined] + status = self.server.account_statuses.pop(0) if self.server.account_statuses else 200 # type: ignore[attr-defined] + delay = self.server.account_delays.pop(0) if self.server.account_delays else 0.0 # type: ignore[attr-defined] + if delay: + time.sleep(delay) + if self.server.echo_account_error: # type: ignore[attr-defined] + secret = record["headers"]["authorization"].split(" ", 1)[-1] # type: ignore[index,union-attr] + data: object = {"error": "Rejected " + secret, "provider_context": "Echo " + secret} + if self.server.echo_account_error == "string": # type: ignore[attr-defined] + data = json.dumps("Rejected " + secret).encode("utf-8") + self._json(503, data, "Echo " + secret, {"X-Provider-Context": "Echo " + secret}) + return + if status != 200: + self._json(status, {"error": "temporary account lookup failure"}) + return + self._json(200, {"data": [{"id": "account-123", "name": "team"}], "total": 1}) + + def do_POST(self) -> None: + record = self._record() + path = urlsplit(str(record["path"])).path + body = record["body"] + fields = parse_qs(body.decode("utf-8")) if isinstance(body, bytes) else {} + if path == "/oauth/register": + self._json(201, {"client_id": self.server.registration_client_id}) # type: ignore[attr-defined] + elif path == "/oauth/device/authorize": + self._device_authorization() + elif path == "/oauth/token": + self._token_response(fields) + else: + self._json(404, {"error": "not found"}) + + def _device_authorization(self) -> None: + error = self.server.authorization_error # type: ignore[attr-defined] + if error: + self._json( + 400, + { + "error": error, + "error_description": self.server.authorization_error_description, # type: ignore[attr-defined] + }, + ) + return + self._json( + 200, + { + "device_code": "local-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": self.server.base_url + "/verify", # type: ignore[attr-defined] + "expires_in": 600, + "interval": self.server.device_interval, # type: ignore[attr-defined] + }, + ) + + def _token_response(self, fields: dict[str, list[str]]) -> None: + grant_type = fields.get("grant_type", [""])[0] + expected_client = self.server.expected_oauth_client_id # type: ignore[attr-defined] + if expected_client and fields.get("client_id") != [expected_client]: + self._json(400, {"error": "invalid_grant"}) + return + if grant_type == "refresh_token": + time.sleep(self.server.refresh_delay) # type: ignore[attr-defined] + if self.server.echo_refresh_error: # type: ignore[attr-defined] + secret = fields["refresh_token"][0] + data: object = { + "error": "temporarily_unavailable", + "error_description": "Rejected credential " + secret, + "provider_context": "Echo " + secret, + } + if self.server.echo_refresh_error == "malformed": # type: ignore[attr-defined] + data = ('{"error_description": ' + secret + ', "refresh_token": "' + secret + '"').encode("utf-8") + self._json( + 503, + data, + "Echo " + secret, + {"Location": "/retry?refresh_token=" + secret, "X-Provider-Context": "Echo " + secret}, + ) + return + self._json( + 200, + { + "access_token": "refreshed-access-token", + "refresh_token": "refreshed-refresh-token", + "expires_in": 3600, + }, + ) + return + if grant_type != _DEVICE_GRANT: + self._json(400, {"error": "unsupported_grant_type"}) + return + if self.server.concurrent_device_polls: # type: ignore[attr-defined] + self._concurrent_device_token_response() + return + if self.server.device_error: # type: ignore[attr-defined] + self._json(400, {"error": self.server.device_error}) # type: ignore[attr-defined] + elif not self.server.approved: # type: ignore[attr-defined] + self._json(400, {"error": "authorization_pending"}) + self.server.first_device_poll_started.set() # type: ignore[attr-defined] + else: + self._json( + 200, + { + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 3600, + }, + ) + + def _concurrent_device_token_response(self) -> None: + with self.server.lock: # type: ignore[attr-defined] + self.server.device_poll_count += 1 # type: ignore[attr-defined] + poll_number = self.server.device_poll_count # type: ignore[attr-defined] + if poll_number == 1: + self.server.first_device_poll_started.set() # type: ignore[attr-defined] + self.server.allow_first_device_poll.wait(timeout=15) # type: ignore[attr-defined] + self._json( + 200, + { + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 3600, + }, + ) + return + self.server.second_device_poll_started.set() # type: ignore[attr-defined] + self.server.allow_second_device_poll.wait(timeout=15) # type: ignore[attr-defined] + self._json(400, {"error": "invalid_grant"}) + + def log_message(self, format: str, *args: object) -> None: + pass + + +class _LocalHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self) -> None: + super().__init__(("127.0.0.1", 0), _Handler) + self.lock = threading.Lock() + self.requests: list[dict[str, object]] = [] + self.approved = False + self.device_error: str | None = None + self.authorization_error: str | None = None + self.authorization_error_description = "device-code-secret" + self.registration_client_id = "local-oauth-client" + self.expected_oauth_client_id: str | None = None + self.device_interval = 1 + self.account_statuses: list[int] = [] + self.account_delays: list[float] = [] + self.refresh_delay = 0.0 + self.echo_refresh_error: str | None = None + self.echo_account_error: str | None = None + self.slow_response_phase: str | None = None + self.slow_response_paths: set[str] = set() + self.server_settings_status: int | None = None + self.reject_api_key = False + self.content_statuses: dict[str, int] = {} + self.concurrent_device_polls = False + self.device_poll_count = 0 + self.first_device_poll_started = threading.Event() + self.second_device_poll_started = threading.Event() + self.allow_first_device_poll = threading.Event() + self.allow_second_device_poll = threading.Event() + local_minor = f"{sys.version_info.major}.{sys.version_info.minor}" + versions = {local_minor + ".0", "3.12.8", "3.11.9"} + self.python_installations = [{"version": version, "publishable": True} for version in sorted(versions)] + self.nodejs_settings = { + "enabled": True, + "installations": [ + {"version": "20.19.0", "publishable": True}, + {"version": "22.22.2", "publishable": True}, + {"version": "24.14.0", "publishable": False}, + ], + "status": {"configured": True, "licensed": True}, + } + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.server_port}" + + +@pytest.fixture +def local_http_server(): + server = _LocalHTTPServer() + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _cli_environment(home: Path) -> dict[str, str]: + home.mkdir(parents=True) + environment = os.environ.copy() + for name in ( + "CONNECT_SERVER", + "CONNECT_API_KEY", + "CONNECT_IDENTITY_TOKEN", + "CONNECT_IDENTITY_TOKEN_FILE", + "CONNECT_CA_CERTIFICATE", + "CONNECT_INSECURE", + "CONNECT_CLOUD_ACCOUNT", + "CONNECT_CLOUD_CLIENT_ID", + "CONNECT_CLOUD_CLIENT_SECRET", + "CONNECT_CLOUD_OAUTH_CLIENT_ID", + "SHINYAPPS_ACCOUNT", + "RSCONNECT_E2E_CLOUD_BASE_URL", + "RSCONNECT_E2E_TOKEN_READ_DELAY", + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + ): + environment.pop(name, None) + environment.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / "xdg"), + "APPDATA": str(home / "appdata"), + "CONNECT_CLOUD_ENVIRONMENT": "production", + "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", + "RSCONNECT_DISABLE_VERSION_CHECK": "1", + } + ) + return environment + + +def _run_cli(arguments: list[str], environment: dict[str, str], cloud_base_url: str | None = None): + child_environment = environment.copy() + if cloud_base_url: + child_environment["RSCONNECT_E2E_CLOUD_BASE_URL"] = cloud_base_url + return subprocess.run( + [sys.executable, "-c", _CLI_BOOTSTRAP, *arguments], + cwd=str(_REPO_ROOT), + env=child_environment, + capture_output=True, + text=True, + timeout=30, + ) + + +def _start_cli(arguments: list[str], environment: dict[str, str]) -> subprocess.Popen[str]: + return subprocess.Popen( + [sys.executable, "-c", _CLI_BOOTSTRAP, *arguments], + cwd=str(_REPO_ROOT), + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def _collect_cli(process: subprocess.Popen[str]) -> subprocess.CompletedProcess[str]: + stdout, stderr = process.communicate(timeout=20) + return subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr) + + +def _output(result: subprocess.CompletedProcess[str]) -> str: + return result.stdout + result.stderr + + +def _json_output(result: subprocess.CompletedProcess[str]) -> dict[str, object]: + assert len(result.stdout.splitlines()) == 1, result.stdout + return json.loads(result.stdout) + + +def _login_connect(name: str, environment: dict[str, str], server: _LocalHTTPServer) -> None: + started = _run_cli( + ["login", "--name", name, "--server", server.base_url, "--no-wait"], + environment, + ) + assert started.returncode == 0, _output(started) + server.approved = True + finished = _run_cli( + ["login", "--name", name, "--finish", "--timeout", "5"], + environment, + ) + assert finished.returncode == 0, _output(finished) + + +def _device_states(home: Path, kind: str) -> list[Path]: + return list(home.rglob(f"device-login-{kind}-*.json")) + + +def _saved_servers(home: Path) -> dict[str, dict[str, object]]: + paths = list(home.rglob("servers.json")) + assert len(paths) == 1 + return json.loads(paths[0].read_text(encoding="utf-8")) + + +def _requests(server: _LocalHTTPServer, path: str) -> list[dict[str, object]]: + with server.lock: + records = list(server.requests) + return [record for record in records if urlsplit(str(record["path"])).path == path] + + +def _form(record: dict[str, object]) -> dict[str, list[str]]: + body = record["body"] + assert isinstance(body, bytes) + return parse_qs(body.decode("utf-8")) + + +def _assert_private_file(path: Path) -> None: + if os.name == "posix": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def _write_node_project(path: Path, name: str, node_requirement: str | None = None) -> dict[str, str]: + path.mkdir() + package: dict[str, object] = {"name": name, "version": "1.0.0"} + if node_requirement is not None: + package["engines"] = {"node": node_requirement} + contents = { + "package.json": json.dumps(package), + "package-lock.json": json.dumps( + { + "name": name, + "version": "1.0.0", + "lockfileVersion": 3, + "requires": True, + "packages": {"": {"name": name, "version": "1.0.0"}}, + } + ), + "index.js": "console.log('ready');\n", + } + for filename, value in contents.items(): + (path / filename).write_text(value, encoding="utf-8") + return contents + + +def test_connect_login_resumes_across_processes_and_preflight_uses_saved_oauth( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "connect-e2e" + start_args = ["login", "--name", name, "--server", local_http_server.base_url, "--no-wait"] + + started = _run_cli(start_args, environment) + assert started.returncode == 0, _output(started) + start_result = _json_output(started) + assert start_result["status"] == "pending" + assert start_result["verification_uri"] == local_http_server.base_url + "/verify" + assert start_result["user_code"] == "ABCD-EFGH" + + state_paths = _device_states(home, "connect") + assert len(state_paths) == 1 + _assert_private_file(state_paths[0]) + state = json.loads(state_paths[0].read_text(encoding="utf-8")) + assert state["name"] == name + assert state["tokens"] is None + + repeated = _run_cli(start_args, environment) + assert repeated.returncode == 0, _output(repeated) + assert _json_output(repeated)["user_code"] == start_result["user_code"] + assert len(_requests(local_http_server, "/.well-known/oauth-authorization-server")) == 1 + assert len(_requests(local_http_server, "/oauth/register")) == 1 + assert len(_requests(local_http_server, "/oauth/device/authorize")) == 1 + + pending = _run_cli(["login", "--name", name, "--finish", "--timeout", "1"], environment) + assert pending.returncode == 0, _output(pending) + assert _json_output(pending)["status"] == "pending" + assert json.loads(state_paths[0].read_text(encoding="utf-8"))["tokens"] is None + + local_http_server.approved = True + finished = _run_cli(["login", "--name", name, "--finish", "--timeout", "5"], environment) + assert finished.returncode == 0, _output(finished) + assert _json_output(finished) == { + "status": "done", + "name": name, + "server": local_http_server.base_url, + } + assert _device_states(home, "connect") == [] + + saved_path = next(home.rglob("servers.json")) + _assert_private_file(saved_path) + saved = _saved_servers(home)[name] + assert saved["url"] == local_http_server.base_url + assert saved["oauth_client_id"] == "local-oauth-client" + assert saved["oauth_access_token"] == "access-token" + assert saved["oauth_refresh_token"] == "refresh-token" + assert saved["default"] is True + + fixed_project = tmp_path / "fixed-project" + fixed_project.mkdir() + fixed = _run_cli( + ["preflight", "--name", name, str(fixed_project), "--fix", "--new"], + environment, + ) + assert fixed.returncode == 0, _output(fixed) + fixed_result = _json_output(fixed) + assert fixed_result["status"] == "ok" + assert fixed_result["changed_files"] == [".python-version"] + assert (fixed_project / ".python-version").read_text(encoding="utf-8") == ( + f"{sys.version_info.major}.{sys.version_info.minor}\n" + ) + + read_only_project = tmp_path / "read-only-project" + read_only_project.mkdir() + read_only_metadata = '[project]\nrequires-python = "==9.9.*"\n' + (read_only_project / "pyproject.toml").write_text(read_only_metadata, encoding="utf-8") + read_only = _run_cli( + ["preflight", "--name", name, str(read_only_project), "--new"], + environment, + ) + assert read_only.returncode == 3, _output(read_only) + read_only_result = _json_output(read_only) + assert read_only_result["status"] == "incompatible" + assert read_only_result["changed_files"] == [] + assert (read_only_project / "pyproject.toml").read_text(encoding="utf-8") == read_only_metadata + + existing_project = tmp_path / "existing-project" + existing_project.mkdir() + (existing_project / "pyproject.toml").write_text('[project]\nrequires-python = "==3.12.*"\n', encoding="utf-8") + existing = _run_cli( + [ + "preflight", + "--name", + name, + str(existing_project), + "--app-id", + _CONTENT_GUID, + ], + environment, + ) + assert existing.returncode == 0, _output(existing) + existing_result = _json_output(existing) + assert existing_result["status"] == "ok" + assert existing_result["existing_content"]["exists"] is True # type: ignore[index] + assert existing_result["existing_content"]["app_id"] == _CONTENT_GUID # type: ignore[index] + assert existing_result["existing_content"]["installed_python_version"] == "3.12.8" # type: ignore[index] + + rest_requests = [record for record in local_http_server.requests if str(record["path"]).startswith("/__api__/")] + assert len(rest_requests) >= 4 + assert all(record["headers"]["authorization"] == "Bearer access-token" for record in rest_requests) # type: ignore[index] + grants = [_form(record)["grant_type"][0] for record in _requests(local_http_server, "/oauth/token")] + assert grants == [_DEVICE_GRANT, _DEVICE_GRANT] + + +def test_node_preflight_uses_saved_oauth_and_preserves_project_files( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + if not shutil.which("node") or not shutil.which("npm"): + pytest.skip("Node.js and npm are required for this integration test.") + node_path = shutil.which("node") + assert node_path is not None + local_node_version = subprocess.check_output([node_path, "--version"], text=True).strip().lstrip("v") + + home = tmp_path / "home" + environment = _cli_environment(home) + name = "node-preflight-e2e" + started = _run_cli( + ["login", "--name", name, "--server", local_http_server.base_url, "--no-wait"], + environment, + ) + assert started.returncode == 0, _output(started) + local_http_server.approved = True + finished = _run_cli(["login", "--name", name, "--finish", "--timeout", "5"], environment) + assert finished.returncode == 0, _output(finished) + + ranged_project = tmp_path / "ranged-node-project" + ranged_files = _write_node_project(ranged_project, "ranged-node-project", ">=22 <23") + + ranged_result = _run_cli( + ["preflight", "--name", name, str(ranged_project), "--runtime", "nodejs", "--new"], + environment, + ) + assert ranged_result.returncode == 0, _output(ranged_result) + ranged_report = _json_output(ranged_result) + assert ranged_report["status"] == "ok" + assert ranged_report["runtime"] == "nodejs" + assert ranged_report["local_node"] == local_node_version + assert ranged_report["node_requires"] == ">=22 <23" + assert ranged_report["publishable_node_versions"] == ["20.19.0", "22.22.2"] + assert ranged_report["changed_files"] == [] + assert {path.name: path.read_text(encoding="utf-8") for path in ranged_project.iterdir()} == ranged_files + rejected_fix = _run_cli( + ["preflight", "--name", name, str(ranged_project), "--runtime", "nodejs", "--new", "--fix"], + environment, + ) + assert rejected_fix.returncode == 2 + assert rejected_fix.stdout == "" + assert "--fix applies only to python projects" in _output(rejected_fix).lower() + assert {path.name: path.read_text(encoding="utf-8") for path in ranged_project.iterdir()} == ranged_files + + unsupported_project = tmp_path / "unsupported-node-project" + unsupported_files = _write_node_project(unsupported_project, "unsupported-node-project", ">=25 <26") + unsupported_result = _run_cli( + ["preflight", "--name", name, str(unsupported_project), "--runtime", "nodejs", "--new"], + environment, + ) + assert unsupported_result.returncode == 3, _output(unsupported_result) + unsupported_report = _json_output(unsupported_result) + assert unsupported_report["status"] == "incompatible" + assert unsupported_report["node_requires"] == ">=25 <26" + assert unsupported_report["changed_files"] == [] + assert {path.name: path.read_text(encoding="utf-8") for path in unsupported_project.iterdir()} == unsupported_files + + unconstrained_project = tmp_path / "unconstrained-node-project" + _write_node_project(unconstrained_project, "unconstrained-node-project") + unconstrained_before = {path.name: path.read_bytes() for path in unconstrained_project.iterdir()} + unconstrained_result = _run_cli( + ["preflight", "--name", name, str(unconstrained_project), "--runtime", "nodejs", "--new"], + environment, + ) + assert unconstrained_result.returncode == 0, _output(unconstrained_result) + unconstrained_report = _json_output(unconstrained_result) + assert unconstrained_report["status"] == "ok" + assert unconstrained_report["node_requires"] is None + assert unconstrained_report["publishable_node_versions"] == ["20.19.0", "22.22.2"] + assert unconstrained_report["changed_files"] == [] + assert {path.name: path.read_bytes() for path in unconstrained_project.iterdir()} == unconstrained_before + + node_settings_requests = _requests(local_http_server, "/__api__/v1/server_settings/nodejs") + assert len(node_settings_requests) == 3 + assert all( + record["headers"]["authorization"] == "Bearer access-token" # type: ignore[index] + for record in node_settings_requests + ) + assert len(_requests(local_http_server, "/oauth/token")) == 1 + + +@pytest.mark.parametrize("status", [403, 404]) +@pytest.mark.parametrize("runtime", ["python", "nodejs"]) +def test_unreadable_explicit_content_returns_actionable_unknown( + tmp_path: Path, + local_http_server: _LocalHTTPServer, + status: int, + runtime: str, +) -> None: + if runtime == "nodejs" and (not shutil.which("node") or not shutil.which("npm")): + pytest.skip("Node.js and npm are required for this integration test.") + + environment = _cli_environment(tmp_path / "home") + name = "missing-content-" + runtime + _login_connect(name, environment, local_http_server) + project = tmp_path / ("project-" + runtime) + if runtime == "nodejs": + _write_node_project(project, project.name, "^22") + runtime_args = ["--runtime", "nodejs"] + else: + project.mkdir() + runtime_args = [] + + local_http_server.content_statuses[_CONTENT_GUID] = status + result = _run_cli( + ["preflight", "--name", name, str(project), "--app-id", _CONTENT_GUID, *runtime_args], + environment, + ) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + existing = report["existing_content"] + assert existing["exists"] is True # type: ignore[index] + assert existing["app_id"] == _CONTENT_GUID # type: ignore[index] + assert report["warnings"] + assert report["actions"] + if status == 404: + assert any("--new" in action for action in report["actions"]) # type: ignore[union-attr] + content_requests = _requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID) + assert len(content_requests) == 1 + assert content_requests[0]["headers"]["authorization"] == "Bearer access-token" # type: ignore[index] + + +@pytest.mark.parametrize("filename", ["manifest.json", "notebook.ipynb", "bundle.tar.gz", "report.qmd"]) +def test_file_deployment_appstore_records_are_seen_by_preflight( + tmp_path: Path, + local_http_server: _LocalHTTPServer, + filename: str, +) -> None: + environment = _cli_environment(tmp_path / "home") + name = "file-appstore-" + filename.replace(".", "-") + _login_connect(name, environment, local_http_server) + + project = tmp_path / "file-project" + project.mkdir() + deployment_file = project / filename + deployment_file.write_bytes(b"deployment input") + store = AppStore(str(deployment_file)) + store.set( + local_http_server.base_url, + str(deployment_file), + "https://connect.example.com/content/" + _CONTENT_GUID, + _CONTENT_GUID, + _CONTENT_GUID, + "saved content", + "python-api", + ) + + result = _run_cli( + ["preflight", "--name", name, str(deployment_file), "--fix"], + environment, + ) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "ok" + assert report["changed_files"] == [] + existing = report["existing_content"] + assert existing["exists"] is True # type: ignore[index] + assert existing["app_id"] == _CONTENT_GUID # type: ignore[index] + assert existing["installed_python_version"] == "3.12.8" # type: ignore[index] + assert not (project / ".python-version").exists() + assert len(_requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID)) == 1 + + +def test_invalid_python_metadata_returns_actionable_unknown_json( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + environment = _cli_environment(tmp_path / "home") + name = "invalid-metadata" + _login_connect(name, environment, local_http_server) + project = tmp_path / "invalid-metadata-project" + project.mkdir() + metadata = project / "pyproject.toml" + original = b"[project\nrequires-python = '>=3.11'\n" + metadata.write_bytes(original) + + result = _run_cli(["preflight", "--name", name, str(project), "--fix", "--new"], environment) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + assert report["changed_files"] == [] + assert report["warnings"] + assert report["actions"] + assert metadata.read_bytes() == original + assert not (project / ".python-version").exists() + + +def test_preflight_operational_errors_emit_the_documented_json_shape(tmp_path: Path) -> None: + environment = _cli_environment(tmp_path / "home") + project = tmp_path / "project" + project.mkdir() + + result = _run_cli(["preflight", "--name", "missing-server", str(project)], environment) + + assert result.returncode == 1 + report = _json_output(result) + assert set(report) == {"status", "runtime", "error", "changed_files", "warnings", "actions"} + assert report["status"] == "error" + assert report["runtime"] == "python" + assert isinstance(report["error"], str) and report["error"] + assert report["changed_files"] == [] + assert report["warnings"] == [] + assert report["actions"] + + +@pytest.mark.parametrize( + ("failure", "request_path"), + [ + ("auth", "/__api__/v1/user"), + ("connection", "/__api__/server_settings"), + ], +) +def test_preflight_http_auth_and_connection_errors_emit_error_json( + tmp_path: Path, + local_http_server: _LocalHTTPServer, + failure: str, + request_path: str, +) -> None: + environment = _cli_environment(tmp_path / "home") + project = tmp_path / "project" + project.mkdir() + if failure == "auth": + local_http_server.reject_api_key = True + else: + local_http_server.server_settings_status = 503 + + result = _run_cli( + [ + "preflight", + "--server", + local_http_server.base_url, + "--api-key", + "synthetic-api-key", + str(project), + "--new", + "-v", + ], + environment, + ) + + assert result.returncode == 1, _output(result) + report = _json_output(result) + assert set(report) == {"status", "runtime", "error", "changed_files", "warnings", "actions"} + assert report["status"] == "error" + assert report["runtime"] == "python" + assert isinstance(report["error"], str) and report["error"] + assert report["changed_files"] == [] + assert report["warnings"] == [] + assert report["actions"] + assert "Checking python runtime availability." in result.stderr + assert "synthetic-api-key" not in _output(result) + assert "preflight-auth-secret" not in _output(result) + requests = _requests(local_http_server, request_path) + assert len(requests) == 1 + if failure == "auth": + assert requests[0]["headers"]["authorization"] == "Key synthetic-api-key" # type: ignore[index] + + +def test_symlinked_python_metadata_returns_actionable_unknown_json( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + environment = _cli_environment(tmp_path / "home") + name = "symlink-metadata" + _login_connect(name, environment, local_http_server) + project = tmp_path / "symlink-metadata-project" + project.mkdir() + target = tmp_path / "real-pyproject.toml" + target.write_text('[project]\nrequires-python = ">=3.11"\n', encoding="utf-8") + metadata = project / "pyproject.toml" + try: + metadata.symlink_to(target) + except (NotImplementedError, OSError) as err: + pytest.skip(f"Cannot create a symlink in this environment: {err}") + + result = _run_cli(["preflight", "--name", name, str(project), "--fix", "--new"], environment) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + assert report["changed_files"] == [] + assert report["warnings"] + assert report["actions"] + assert metadata.is_symlink() + assert metadata.read_text(encoding="utf-8") == target.read_text(encoding="utf-8") + assert not (project / ".python-version").exists() + + +def test_incomplete_appstore_record_returns_actionable_unknown_without_fixing( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + environment = _cli_environment(tmp_path / "home") + name = "incomplete-appstore" + _login_connect(name, environment, local_http_server) + project = tmp_path / "incomplete-record-project" + project.mkdir() + app_file = project / (project.name + ".py") + app_file.write_text("pass\n", encoding="utf-8") + AppStore(str(app_file)).set(local_http_server.base_url, str(app_file), "", "", None, "unfinished", "python-api") + + result = _run_cli(["preflight", "--name", name, str(project), "--fix"], environment) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + assert report["changed_files"] == [] + assert report["warnings"] + assert any("--new" in action or "--app-id" in action for action in report["actions"]) # type: ignore[union-attr] + assert not (project / ".python-version").exists() + + +@pytest.mark.parametrize( + ("device_error", "message"), + [ + ("access_denied", "denied"), + ("expired_token", "expired"), + ("invalid_grant", "invalid"), + ], +) +def test_terminal_device_oauth_errors_remove_pending_state( + tmp_path: Path, + local_http_server: _LocalHTTPServer, + device_error: str, + message: str, +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + local_http_server.device_error = device_error + started = _run_cli( + ["login", "--name", "terminal-login", "--server", local_http_server.base_url, "--no-wait"], + environment, + ) + assert started.returncode == 0, _output(started) + assert len(_device_states(home, "connect")) == 1 + + finished = _run_cli( + ["login", "--name", "terminal-login", "--finish", "--timeout", "5"], + environment, + ) + assert finished.returncode == 1 + assert finished.stdout == "" + assert message in finished.stderr.lower() + assert "local-device-code" not in _output(finished) + assert _device_states(home, "connect") == [] + assert len(_requests(local_http_server, "/oauth/token")) == 1 + + +def test_expired_persisted_device_login_is_removed_without_polling( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + started = _run_cli( + ["login", "--name", "old-login", "--server", local_http_server.base_url, "--no-wait"], + environment, + ) + assert started.returncode == 0, _output(started) + + state_path = _device_states(home, "connect")[0] + state = json.loads(state_path.read_text(encoding="utf-8")) + state["expires_at"] = time.time() - 1 + state_path.write_text(json.dumps(state), encoding="utf-8") + finished = _run_cli(["login", "--name", "old-login", "--finish"], environment) + + assert finished.returncode == 1 + assert "expired" in _output(finished).lower() + assert _device_states(home, "connect") == [] + assert _requests(local_http_server, "/oauth/token") == [] + + +def test_authorization_pending_retry_obeys_the_saved_poll_interval( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "poll-interval-e2e" + local_http_server.device_interval = 2 + started = _run_cli( + ["login", "--name", name, "--server", local_http_server.base_url, "--no-wait"], + environment, + ) + assert started.returncode == 0, _output(started) + + # Let the initial interval pass so the first finish call receives a real + # authorization_pending response before its one-second deadline. + time.sleep(2.05) + first = _run_cli(["login", "--name", name, "--finish", "--timeout", "1"], environment) + assert first.returncode == 0, _output(first) + assert _json_output(first)["status"] == "pending" + first_polls = _requests(local_http_server, "/oauth/token") + assert len(first_polls) == 1 + + retried = _run_cli(["login", "--name", name, "--finish", "--timeout", "1"], environment) + assert retried.returncode == 0, _output(retried) + assert _json_output(retried)["status"] == "pending" + polls = _requests(local_http_server, "/oauth/token") + assert len(polls) <= 2 + if len(polls) == 2: + assert polls[1]["received_at"] - polls[0]["received_at"] >= 1.8 # type: ignore[operator] + + +def test_concurrent_finish_cannot_resurrect_stale_pending_state( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "concurrent-finish-e2e" + started = _run_cli( + ["login", "--name", name, "--server", local_http_server.base_url, "--no-wait"], + environment, + ) + assert started.returncode == 0, _output(started) + state_paths = _device_states(home, "connect") + assert len(state_paths) == 1 + + local_http_server.approved = True + local_http_server.concurrent_device_polls = True + finish_args = ["login", "--name", name, "--finish", "--timeout", "5"] + first = _start_cli(finish_args, environment) + second: subprocess.Popen[str] | None = None + try: + assert local_http_server.first_device_poll_started.wait(timeout=10) + second = _start_cli(["login", "--name", name, "--finish", "--timeout", "1"], environment) + second_result = _collect_cli(second) + assert second_result.returncode == 0, _output(second_result) + second_report = _json_output(second_result) + assert set(second_report) == {"status", "name", "server"} + assert second_report == {"status": "pending", "name": name, "server": None} + + local_http_server.allow_first_device_poll.set() + first_result = _collect_cli(first) + assert first_result.returncode == 0, _output(first_result) + assert _json_output(first_result)["status"] == "done" + finally: + local_http_server.allow_first_device_poll.set() + local_http_server.allow_second_device_poll.set() + for process in (first, second): + if process is not None and process.poll() is None: + process.kill() + process.communicate(timeout=5) + + assert _device_states(home, "connect") == [] + device_polls = [ + request + for request in _requests(local_http_server, "/oauth/token") + if _form(request)["grant_type"] == [_DEVICE_GRANT] + ] + assert len(device_polls) == 1 + + +@pytest.mark.parametrize("verbose", ["-v", "-vv"]) +def test_device_login_verbose_output_keeps_json_clean_and_hides_secrets( + tmp_path: Path, local_http_server: _LocalHTTPServer, verbose: str +) -> None: + environment = _cli_environment(tmp_path / "home") + name = "verbose-login-e2e" + started = _run_cli( + ["login", "--name", name, "--server", local_http_server.base_url, "--no-wait", verbose], + environment, + ) + + assert started.returncode == 0, _output(started) + assert _json_output(started)["status"] == "pending" + assert "Starting device authentication." in started.stderr + local_http_server.approved = True + + finished = _run_cli( + ["login", "--name", name, "--finish", "--timeout", "5", verbose], + environment, + ) + + assert finished.returncode == 0, _output(finished) + assert _json_output(finished)["status"] == "done" + assert "Finishing device authentication." in finished.stderr + if verbose == "-vv": + assert "[DEBUG]" in started.stderr + assert "Request: POST" in started.stderr + assert "Request: POST" in finished.stderr + assert "" in started.stderr + assert "" in finished.stderr + else: + assert "[DEBUG]" not in started.stderr + assert "[DEBUG]" not in finished.stderr + for secret in ("local-device-code", "access-token", "refresh-token"): + assert secret not in _output(started) + assert secret not in _output(finished) + + +def test_device_login_errors_use_stderr_and_redact_server_descriptions( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + environment = _cli_environment(tmp_path / "home") + local_http_server.authorization_error = "invalid_request" + result = _run_cli( + [ + "login", + "--name", + "failed-login-e2e", + "--server", + local_http_server.base_url, + "--no-wait", + "-v", + ], + environment, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert result.stderr.strip() + assert "Error:" in result.stderr + assert "device-code-secret" not in _output(result) + + +def test_cloud_finish_checkpoints_tokens_and_retries_account_lookup( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + local_http_server.account_statuses = [500, 401, 200] + cloud_args = ["add", "--connect-cloud", "--account", "team", "--name", "cloud-e2e", "--no-wait"] + + started = _run_cli(cloud_args, environment, cloud_base_url=local_http_server.base_url) + assert started.returncode == 0, _output(started) + assert _json_output(started)["status"] == "pending" + state_paths = _device_states(home, "cloud") + assert len(state_paths) == 1 + + repeated = _run_cli(cloud_args, environment, cloud_base_url=local_http_server.base_url) + assert repeated.returncode == 0, _output(repeated) + assert len(_requests(local_http_server, "/oauth/device/authorize")) == 1 + device_form = _form(_requests(local_http_server, "/oauth/device/authorize")[0]) + assert device_form["scope"] == ["vivid"] + + pending = _run_cli( + ["add", "--connect-cloud", "--name", "cloud-e2e", "--finish", "--timeout", "1"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert pending.returncode == 0, _output(pending) + assert _json_output(pending)["status"] == "pending" + + local_http_server.approved = True + failed_lookup = _run_cli( + ["add", "--connect-cloud", "--name", "cloud-e2e", "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert failed_lookup.returncode == 1 + checkpoint = json.loads(state_paths[0].read_text(encoding="utf-8"))["tokens"] + assert checkpoint["access_token"] == "access-token" + assert checkpoint["refresh_token"] == "refresh-token" + assert checkpoint["expires_at"] > time.time() + + finished = _run_cli( + ["add", "--connect-cloud", "--name", "cloud-e2e", "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert finished.returncode == 0, _output(finished) + assert _json_output(finished) == { + "status": "done", + "name": "cloud-e2e", + "server": local_http_server.base_url + "/v1", + "account": "team", + } + assert _device_states(home, "cloud") == [] + + saved_path = next(home.rglob("servers.json")) + _assert_private_file(saved_path) + saved = _saved_servers(home)["cloud-e2e"] + assert saved["connect_cloud_account_name"] == "team" + assert saved["connect_cloud_account_id"] == "account-123" + assert saved["connect_cloud_access_token"] == "refreshed-access-token" + assert saved["connect_cloud_refresh_token"] == "refreshed-refresh-token" + + account_requests = _requests(local_http_server, "/v1/accounts") + assert [record["headers"]["authorization"] for record in account_requests] == [ # type: ignore[index] + "Bearer access-token", + "Bearer access-token", + "Bearer refreshed-access-token", + ] + grants = [_form(record)["grant_type"][0] for record in _requests(local_http_server, "/oauth/token")] + assert grants == [_DEVICE_GRANT, _DEVICE_GRANT, "refresh_token"] + + +def test_cloud_unknown_account_clears_state_for_a_corrected_start( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "cloud-account-correction" + started = _run_cli( + ["add", "--connect-cloud", "--account", "teem", "--name", name, "--no-wait"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert started.returncode == 0, _output(started) + local_http_server.approved = True + + failed_lookup = _run_cli( + ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + + assert failed_lookup.returncode == 1 + assert _device_states(home, "cloud") == [] + assert "access-token" not in _output(failed_lookup) + + corrected_start = _run_cli( + ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert corrected_start.returncode == 0, _output(corrected_start) + corrected_finish = _run_cli( + ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + + assert corrected_finish.returncode == 0, _output(corrected_finish) + assert _json_output(corrected_finish)["status"] == "done" + assert _device_states(home, "cloud") == [] + assert len(_requests(local_http_server, "/oauth/device/authorize")) == 2 + + +def test_cloud_rotated_tokens_are_checkpointed_after_transient_account_lookup_failure( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "cloud-rotated-checkpoint" + local_http_server.account_statuses = [401, 500] + start_args = ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"] + started = _run_cli(start_args, environment, cloud_base_url=local_http_server.base_url) + assert started.returncode == 0, _output(started) + state_path = _device_states(home, "cloud")[0] + + local_http_server.approved = True + failed_lookup = _run_cli( + ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert failed_lookup.returncode == 1 + checkpoint = json.loads(state_path.read_text(encoding="utf-8"))["tokens"] + assert checkpoint["access_token"] == "refreshed-access-token" + assert checkpoint["refresh_token"] == "refreshed-refresh-token" + + local_http_server.account_statuses = [200] + retried = _run_cli( + ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert retried.returncode == 0, _output(retried) + assert _json_output(retried)["status"] == "done" + assert _device_states(home, "cloud") == [] + account_requests = _requests(local_http_server, "/v1/accounts") + assert [request["headers"]["authorization"] for request in account_requests] == [ # type: ignore[index] + "Bearer access-token", + "Bearer refreshed-access-token", + "Bearer refreshed-access-token", + ] + grants = [_form(record)["grant_type"][0] for record in _requests(local_http_server, "/oauth/token")] + assert grants == [_DEVICE_GRANT, "refresh_token"] + + +def test_cloud_finish_deadline_covers_delayed_refresh_and_keeps_checkpoint( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "cloud-deadline-e2e" + cloud_args = ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"] + started = _run_cli(cloud_args, environment, cloud_base_url=local_http_server.base_url) + assert started.returncode == 0, _output(started) + state_path = _device_states(home, "cloud")[0] + + local_http_server.approved = True + # Let the one-second device-poll interval pass so the finish budget covers the + # account lookup and standalone token refresh, not an initial polling sleep. + time.sleep(1.1) + local_http_server.account_statuses = [401] + local_http_server.account_delays = [1.5] + local_http_server.refresh_delay = 1.5 + finish_args = ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "2"] + started_at = time.monotonic() + timed_out = _run_cli(finish_args, environment, cloud_base_url=local_http_server.base_url) + elapsed = time.monotonic() - started_at + + assert timed_out.returncode == 0, _output(timed_out) + assert _json_output(timed_out) == { + "status": "pending", + "name": name, + "server": local_http_server.base_url + "/v1", + } + assert elapsed < 2.8, f"finish exceeded its two-second budget: {elapsed:.2f}s" + checkpoint = json.loads(state_path.read_text(encoding="utf-8"))["tokens"] + assert checkpoint["access_token"] == "access-token" + assert checkpoint["refresh_token"] == "refresh-token" + account_requests = _requests(local_http_server, "/v1/accounts") + assert [record["headers"]["authorization"] for record in account_requests] == ["Bearer access-token"] + grants = [_form(record)["grant_type"][0] for record in _requests(local_http_server, "/oauth/token")] + assert grants == [_DEVICE_GRANT, "refresh_token"] + + local_http_server.refresh_delay = 0 + retried = _run_cli( + ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert retried.returncode == 0, _output(retried) + assert _json_output(retried)["status"] == "done" + assert _device_states(home, "cloud") == [] + account_requests = _requests(local_http_server, "/v1/accounts") + assert [record["headers"]["authorization"] for record in account_requests] == [ + "Bearer access-token", + "Bearer access-token", + ] + assert [_form(record)["grant_type"][0] for record in _requests(local_http_server, "/oauth/token")] == [ + _DEVICE_GRANT, + "refresh_token", + ] + saved = _saved_servers(home)[name] + assert saved["connect_cloud_access_token"] == "access-token" + assert saved["connect_cloud_refresh_token"] == "refresh-token" + + +@pytest.mark.parametrize("kind", ["connect", "cloud"]) +def test_completed_token_response_is_checkpointed_after_deadline_and_device_expiry( + tmp_path: Path, local_http_server: _LocalHTTPServer, kind: str +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "completed-token-" + kind + if kind == "cloud": + start_args = ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"] + finish_args = ["add", "--connect-cloud", "--name", name, "--finish"] + cloud_base = local_http_server.base_url + else: + start_args = ["login", "--server", local_http_server.base_url, "--name", name, "--no-wait"] + finish_args = ["login", "--name", name, "--finish"] + cloud_base = None + + started = _run_cli(start_args, environment, cloud_base_url=cloud_base) + assert started.returncode == 0, _output(started) + local_http_server.approved = True + time.sleep(1.1) + delayed_environment = {**environment, "RSCONNECT_E2E_TOKEN_READ_DELAY": "1.2"} + pending = _run_cli([*finish_args, "--timeout", "1"], delayed_environment, cloud_base_url=cloud_base) + + assert pending.returncode == 0, _output(pending) + assert _json_output(pending)["status"] == "pending" + state_path = _device_states(home, kind)[0] + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["tokens"]["access_token"] == "access-token" + assert state["tokens"]["refresh_token"] == "refresh-token" + state["expires_at"] = time.time() - 1 + state_path.write_text(json.dumps(state), encoding="utf-8") + local_http_server.device_error = "invalid_grant" + + restarted = _run_cli(start_args, environment, cloud_base_url=cloud_base) + assert restarted.returncode == 0, _output(restarted) + assert _json_output(restarted)["expires_in"] == 0 + assert json.loads(state_path.read_text(encoding="utf-8"))["tokens"] == state["tokens"] + assert len(_requests(local_http_server, "/oauth/device/authorize")) == 1 + + finished = _run_cli([*finish_args, "--timeout", "5"], environment, cloud_base_url=cloud_base) + assert finished.returncode == 0, _output(finished) + assert _json_output(finished)["status"] == "done" + assert not _device_states(home, kind) + assert len(_requests(local_http_server, "/oauth/token")) == 1 + saved = _saved_servers(home)[name] + token_field = "connect_cloud_access_token" if kind == "cloud" else "oauth_access_token" + assert saved[token_field] == "access-token" + for result in (started, pending, restarted, finished): + assert "access-token" not in _output(result) + assert "refresh-token" not in _output(result) + assert "local-device-code" not in _output(result) + + +@pytest.mark.parametrize("phase", ["headers", "body"]) +@pytest.mark.parametrize("kind", ["connect", "cloud"]) +def test_finish_deadline_interrupts_slow_responses_and_can_resume( + tmp_path: Path, local_http_server: _LocalHTTPServer, phase: str, kind: str +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "slow-response-" + kind + if kind == "cloud": + start_args = ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"] + finish_args = ["add", "--connect-cloud", "--name", name, "--finish"] + cloud_base = local_http_server.base_url + slow_path = "/v1/accounts" + else: + start_args = ["login", "--server", local_http_server.base_url, "--name", name, "--no-wait"] + finish_args = ["login", "--name", name, "--finish"] + cloud_base = None + slow_path = "/oauth/token" + started = _run_cli(start_args, environment, cloud_base_url=cloud_base) + assert started.returncode == 0, _output(started) + local_http_server.approved = True + time.sleep(1.1) + local_http_server.slow_response_phase = phase + local_http_server.slow_response_paths = {slow_path} + + started_at = time.monotonic() + timed_out = _run_cli([*finish_args, "--timeout", "2"], environment, cloud_base_url=cloud_base) + elapsed = time.monotonic() - started_at + + assert timed_out.returncode == 0, _output(timed_out) + assert _json_output(timed_out)["status"] == "pending" + assert elapsed < 3.2, f"slow {phase} bypassed the finish deadline: {elapsed:.2f}s" + state = json.loads(_device_states(home, kind)[0].read_text(encoding="utf-8")) + if kind == "cloud": + assert state["tokens"]["access_token"] == "access-token" + else: + assert state["tokens"] is None + assert state["last_poll_at"] > 0 + assert not list(home.rglob("servers.json")) + assert "local-device-code" not in _output(timed_out) + assert "refresh-token" not in _output(timed_out) + + local_http_server.slow_response_paths.clear() + retried = _run_cli([*finish_args, "--timeout", "5"], environment, cloud_base_url=cloud_base) + assert retried.returncode == 0, _output(retried) + assert _json_output(retried)["status"] == "done" + assert not _device_states(home, kind) + token_requests = _requests(local_http_server, "/oauth/token") + assert len(token_requests) == (1 if kind == "cloud" else 2) + + +def test_invalid_registration_client_id_leaves_no_state_and_allows_corrected_start( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + local_http_server.registration_client_id = "invalid\nclient" + args = ["login", "--server", local_http_server.base_url, "--name", "invalid-client", "--no-wait"] + + rejected = _run_cli(args, environment) + + assert rejected.returncode == 1 + assert rejected.stdout == "" + assert "client ID" in rejected.stderr + assert not _device_states(home, "connect") + assert not list(home.rglob("servers.json")) + local_http_server.registration_client_id = "local-oauth-client" + corrected = _run_cli(args, environment) + assert corrected.returncode == 0, _output(corrected) + assert _json_output(corrected)["status"] == "pending" + + +def test_existing_blocking_device_login_handles_pending_http_responses( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "blocking-device-login" + process = _start_cli( + ["login", "--server", local_http_server.base_url, "--name", name, "--use-device-code"], + environment, + ) + try: + assert local_http_server.first_device_poll_started.wait(timeout=10) + local_http_server.approved = True + finished = _collect_cli(process) + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + + assert finished.returncode == 0, _output(finished) + assert "ABCD-EFGH" in finished.stdout + assert "Waiting for authorization" in finished.stdout + assert len(_requests(local_http_server, "/oauth/token")) == 2 + assert _saved_servers(home)[name]["oauth_access_token"] == "access-token" + assert not _device_states(home, "connect") + for request in _requests(local_http_server, "/oauth/token"): + assert _form(request)["grant_type"] == [_DEVICE_GRANT] + assert _form(request)["client_id"] == ["local-oauth-client"] + + +@pytest.mark.parametrize("verbose", [[], ["-vv"]]) +@pytest.mark.parametrize("response_kind", ["json", "malformed"]) +def test_cloud_refresh_error_does_not_leak_credentials_and_can_resume( + tmp_path: Path, local_http_server: _LocalHTTPServer, verbose: list[str], response_kind: str +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "private-refresh-errors" + started = _run_cli( + ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert started.returncode == 0, _output(started) + local_http_server.approved = True + local_http_server.account_statuses = [401] + local_http_server.echo_refresh_error = response_kind + finish_args = ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5", *verbose] + + failed = _run_cli(finish_args, environment, cloud_base_url=local_http_server.base_url) + + assert failed.returncode == 1, _output(failed) + assert failed.stdout == "" + assert "token refresh failed" in failed.stderr + for secret in ("device-code-secret", "access-token", "refresh-token"): + assert secret not in _output(failed) + forms = [_form(request) for request in _requests(local_http_server, "/oauth/token")] + assert [form["grant_type"] for form in forms] == [[_DEVICE_GRANT], ["refresh_token"]] + assert forms[-1]["refresh_token"] == ["refresh-token"] + checkpoint = json.loads(_device_states(home, "cloud")[0].read_text(encoding="utf-8"))["tokens"] + assert checkpoint["refresh_token"] == "refresh-token" + + local_http_server.echo_refresh_error = None + local_http_server.account_statuses = [401, 200] + retried = _run_cli(finish_args, environment, cloud_base_url=local_http_server.base_url) + assert retried.returncode == 0, _output(retried) + assert _json_output(retried)["status"] == "done" + assert not _device_states(home, "cloud") + for secret in ("device-code-secret", "access-token", "refresh-token"): + assert secret not in _output(retried) + + +@pytest.mark.parametrize("verbose", [[], ["-vv"]]) +@pytest.mark.parametrize("response_kind", ["json", "string"]) +def test_cloud_account_error_does_not_leak_credentials_and_can_resume( + tmp_path: Path, local_http_server: _LocalHTTPServer, verbose: list[str], response_kind: str +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "private-account-errors" + started = _run_cli( + ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert started.returncode == 0, _output(started) + local_http_server.approved = True + local_http_server.echo_account_error = response_kind + finish_args = ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5", *verbose] + + failed = _run_cli(finish_args, environment, cloud_base_url=local_http_server.base_url) + + assert failed.returncode == 1, _output(failed) + assert failed.stdout == "" + assert "account lookup failed" in failed.stderr.lower() + for secret in ("device-code-secret", "access-token", "refresh-token"): + assert secret not in _output(failed) + checkpoint = json.loads(_device_states(home, "cloud")[0].read_text(encoding="utf-8"))["tokens"] + assert checkpoint["access_token"] == "access-token" + assert checkpoint["refresh_token"] == "refresh-token" + assert _requests(local_http_server, "/v1/accounts")[-1]["headers"]["authorization"] == "Bearer access-token" # type: ignore[index] + + local_http_server.echo_account_error = None + retried = _run_cli(finish_args, environment, cloud_base_url=local_http_server.base_url) + + assert retried.returncode == 0, _output(retried) + assert _json_output(retried)["status"] == "done" + assert not _device_states(home, "cloud") + for secret in ("device-code-secret", "access-token", "refresh-token"): + assert secret not in _output(retried) + + +def test_cloud_finish_refresh_uses_the_client_id_saved_at_start( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + environment["CONNECT_CLOUD_OAUTH_CLIENT_ID"] = "initial-client" + name = "saved-cloud-client" + started = _run_cli( + ["add", "--connect-cloud", "--account", "team", "--name", name, "--no-wait"], + environment, + cloud_base_url=local_http_server.base_url, + ) + assert started.returncode == 0, _output(started) + assert _form(_requests(local_http_server, "/oauth/device/authorize")[0])["client_id"] == ["initial-client"] + local_http_server.approved = True + local_http_server.account_statuses = [401, 200] + local_http_server.expected_oauth_client_id = "initial-client" + environment["CONNECT_CLOUD_OAUTH_CLIENT_ID"] = "changed-client" + + finished = _run_cli( + ["add", "--connect-cloud", "--name", name, "--finish", "--timeout", "5"], + environment, + cloud_base_url=local_http_server.base_url, + ) + + assert finished.returncode == 0, _output(finished) + assert _json_output(finished)["status"] == "done" + token_forms = [_form(record) for record in _requests(local_http_server, "/oauth/token")] + assert [form["grant_type"] for form in token_forms] == [[_DEVICE_GRANT], ["refresh_token"]] + assert [form["client_id"] for form in token_forms] == [["initial-client"], ["initial-client"]] + saved = _saved_servers(home)[name] + assert saved["connect_cloud_access_token"] == "refreshed-access-token" + assert saved["connect_cloud_refresh_token"] == "refreshed-refresh-token" + assert not _device_states(home, "cloud") + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX FIFOs and symlinks are required") +@pytest.mark.parametrize("runtime", ["python", "nodejs"]) +@pytest.mark.parametrize("record_kind", ["module-symlink", "manifest-symlink", "module-fifo"]) +def test_preflight_rejects_unsafe_deployment_records_without_reading_or_fixing( + tmp_path: Path, local_http_server: _LocalHTTPServer, runtime: str, record_kind: str +) -> None: + environment = _cli_environment(tmp_path / "home") + project = tmp_path / "unsafe-record-project" + if runtime == "nodejs": + _write_node_project(project, project.name, "^22") + else: + project.mkdir() + metadata_directory = project / "rsconnect-python" + metadata_directory.mkdir() + filename = "manifest.json" if record_kind == "manifest-symlink" else project.name + ".json" + record = metadata_directory / filename + outside = tmp_path / "outside.json" + outside.write_text( + json.dumps( + { + local_http_server.base_url: { + "app_id": _CONTENT_GUID, + "app_guid": _CONTENT_GUID, + "app_mode": "python-api", + "version": 1, + } + } + ), + encoding="utf-8", + ) + original = outside.read_bytes() + if record_kind == "module-fifo": + os.mkfifo(record) + else: + record.symlink_to(outside) + runtime_args = ["--runtime", "nodejs"] if runtime == "nodejs" else ["--fix"] + + result = _run_cli( + [ + "preflight", + "--server", + local_http_server.base_url, + "--api-key", + "local-key", + str(project), + *runtime_args, + ], + environment, + ) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + assert report["changed_files"] == [] + assert report["actions"] + assert report["warnings"] + assert any("regular" in warning.lower() or "symlink" in warning.lower() for warning in report["warnings"]) + assert outside.read_bytes() == original + assert not (project / ".python-version").exists() + assert not _requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID) + + +def test_oversized_valid_deployment_record_is_unknown_and_explicit_id_bypasses_it( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + environment = _cli_environment(tmp_path / "home") + project = tmp_path / "oversized-project" + project.mkdir() + metadata_directory = project / "rsconnect-python" + metadata_directory.mkdir() + record = metadata_directory / (project.name + ".json") + record.write_text( + json.dumps( + { + local_http_server.base_url: { + "app_id": _CONTENT_GUID, + "app_guid": _CONTENT_GUID, + "app_mode": "python-api", + "version": 1, + "title": "x" * (2 * 1024 * 1024), + } + } + ), + encoding="utf-8", + ) + arguments = [ + "preflight", + "--server", + local_http_server.base_url, + "--api-key", + "local-key", + str(project), + "--fix", + ] + + result = _run_cli(arguments, environment) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + assert report["changed_files"] == [] + assert any("limit" in warning for warning in report["warnings"]) + assert report["actions"] + assert not (project / ".python-version").exists() + assert not _requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID) + + explicit = _run_cli([*arguments, "--app-id", _CONTENT_GUID], environment) + assert explicit.returncode == 0, _output(explicit) + assert _json_output(explicit)["status"] == "ok" + assert _json_output(explicit)["existing_content"]["app_id"] == _CONTENT_GUID # type: ignore[index] + assert not (project / ".python-version").exists() + assert len(_requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID)) == 1 + + +@pytest.mark.parametrize("runtime", ["python", "nodejs"]) +@pytest.mark.parametrize( + "contents, warning_text", + [ + pytest.param("[" * 10000 + "0" + "]" * 10000, "deployment metadata", id="nested-json"), + pytest.param( + '{"app_id":' + "9" * 5000 + "}", + "integer string conversion", + id="integer-digit-limit", + marks=pytest.mark.skipif( + not 0 < getattr(sys, "get_int_max_str_digits", lambda: 0)() < 5000, + reason="Interpreter does not enforce this integer digit limit.", + ), + ), + ], +) +def test_unusable_deployment_record_is_unknown_without_fixing( + tmp_path: Path, local_http_server: _LocalHTTPServer, runtime: str, contents: str, warning_text: str +) -> None: + environment = _cli_environment(tmp_path / "home") + project = tmp_path / "nested-project" + if runtime == "nodejs": + _write_node_project(project, project.name, "^22") + else: + project.mkdir() + metadata_directory = project / "rsconnect-python" + metadata_directory.mkdir() + record = metadata_directory / (project.name + ".json") + record.write_text(contents, encoding="utf-8") + runtime_args = ["--runtime", "nodejs"] if runtime == "nodejs" else ["--fix"] + + result = _run_cli( + ["preflight", "--server", local_http_server.base_url, "--api-key", "local-key", str(project), *runtime_args], + environment, + ) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "unknown" + assert report["changed_files"] == [] + assert report["actions"] + assert any(warning_text in warning.lower() for warning in report["warnings"]) + assert record.read_text(encoding="utf-8") == contents + assert not (project / ".python-version").exists() + assert not _requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID) + + +def test_resumable_reauthentication_preserves_legacy_url_and_content_history( + tmp_path: Path, local_http_server: _LocalHTTPServer +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + name = "legacy-url" + saved_url = local_http_server.base_url + "/" + local_http_server.approved = True + legacy = _run_cli( + ["login", saved_url, "--name", name, "--use-device-code", "--client-id", "local-oauth-client"], + environment, + ) + assert legacy.returncode == 0, _output(legacy) + assert _saved_servers(home)[name]["url"] == saved_url + project = tmp_path / "legacy-project" + project.mkdir() + app_file = str(project / (project.name + ".py")) + history = AppStore(app_file) + history.set( + saved_url, + app_file, + saved_url + "content/" + _CONTENT_GUID, + _CONTENT_GUID, + _CONTENT_GUID, + "Existing content", + "python-api", + ) + history_path = Path(history.get_path()) + original_history = history_path.read_bytes() + started = _run_cli(["login", saved_url, "--name", name, "--no-wait"], environment) + assert started.returncode == 0, _output(started) + + finished = _run_cli(["login", "--name", name, "--finish", "--timeout", "5"], environment) + + assert finished.returncode == 0, _output(finished) + assert _json_output(finished)["server"] == local_http_server.base_url + assert _saved_servers(home)[name]["url"] == saved_url + assert _device_states(home, "connect") == [] + named = _run_cli(["preflight", "--name", name, str(project), "--fix"], environment) + assert named.returncode == 0, _output(named) + named_report = _json_output(named) + assert named_report["existing_content"]["app_id"] == _CONTENT_GUID # type: ignore[index] + assert named_report["changed_files"] == [] + inferred = _run_cli(["preflight", str(project), "--fix"], environment) + assert inferred.returncode == 0, _output(inferred) + inferred_report = _json_output(inferred) + assert inferred_report["server"] == saved_url + assert inferred_report["existing_content"]["app_id"] == _CONTENT_GUID # type: ignore[index] + assert inferred_report["changed_files"] == [] + assert len(_requests(local_http_server, "/__api__/v1/content/" + _CONTENT_GUID)) == 2 + assert history_path.read_bytes() == original_history + assert not (project / ".python-version").exists() + + +@pytest.mark.parametrize("filename", [None, "app.py", "manifest.json"]) +def test_safe_deployment_history_still_selects_its_server_before_the_default( + tmp_path: Path, local_http_server: _LocalHTTPServer, filename: str | None +) -> None: + home = tmp_path / "home" + environment = _cli_environment(home) + _login_connect("history-target", environment, local_http_server) + servers = _saved_servers(home) + servers["history-target"]["default"] = False + servers["other-default"] = { + "name": "other-default", + "url": "http://127.0.0.1:1", + "api_key": "unused-key", + "default": True, + } + next(home.rglob("servers.json")).write_text(json.dumps(servers), encoding="utf-8") + project = tmp_path / "history-target-project" + project.mkdir() + module_file = project / (filename or project.name + ".py") + if filename: + module_file.write_text("{}" if filename.endswith(".json") else "app = object()\n", encoding="utf-8") + AppStore(str(module_file)).set( + local_http_server.base_url, + str(module_file), + local_http_server.base_url + "/content/" + _CONTENT_GUID, + _CONTENT_GUID, + _CONTENT_GUID, + "saved content", + "python-api", + ) + + target = module_file if filename else project + result = _run_cli(["preflight", str(target), "--fix"], environment) + + assert result.returncode == 0, _output(result) + report = _json_output(result) + assert report["status"] == "ok" + assert report["server"] == local_http_server.base_url + assert report["existing_content"]["app_id"] == _CONTENT_GUID # type: ignore[index] + assert report["changed_files"] == [] + assert not (project / ".python-version").exists() + assert _saved_servers(home)["other-default"]["default"] is True diff --git a/tests/test_agent_platform.py b/tests/test_agent_platform.py new file mode 100644 index 00000000..2ea56a42 --- /dev/null +++ b/tests/test_agent_platform.py @@ -0,0 +1,107 @@ +"""Keep new agent commands POSIX-only without restricting existing commands.""" + +import inspect +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from click.testing import CliRunner + +from rsconnect import device_login, main, oauth, preflight, preflight_node, validation +from rsconnect.exception import RSConnectException +from rsconnect.metadata import ServerStore + + +def cli_runner(): + options = {"mix_stderr": False} if "mix_stderr" in inspect.signature(CliRunner).parameters else {} + return CliRunner(**options) + + +@pytest.fixture +def unsupported_platform(monkeypatch, tmp_path): + monkeypatch.setattr(validation, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(main, "server_store", ServerStore(base_dir=str(tmp_path))) + for variable in ("CONNECT_SERVER", "CONNECT_IDENTITY_TOKEN", "CONNECT_IDENTITY_TOKEN_FILE"): + monkeypatch.delenv(variable, raising=False) + yield + from rsconnect.actions import set_verbosity + + set_verbosity(0) + + +@pytest.mark.parametrize( + "function,arguments", + [ + (device_login.start_connect_login, ("https://example.test", "target")), + (device_login.start_cloud_login, ("team", "target")), + (device_login.finish_login, ("connect", "target")), + (device_login.finish_login, ("cloud", "target")), + ], +) +def test_resumable_api_rejects_windows_before_state_access(unsupported_platform, monkeypatch, function, arguments): + lock = Mock() + monkeypatch.setattr(device_login, "_state_lock", lock) + with pytest.raises(RSConnectException, match="supported only on POSIX"): + function(*arguments) + lock.assert_not_called() + + +@pytest.mark.parametrize( + "command", + [ + ["login", "https://example.test", "--no-wait"], + ["login", "--name", "target", "--finish"], + ["add", "--connect-cloud", "--account", "team", "--name", "target", "--no-wait"], + ["add", "--connect-cloud", "--name", "target", "--finish"], + ["server", "add", "--connect-cloud", "--account", "team", "--name", "target", "--no-wait"], + ["server", "add", "--connect-cloud", "--name", "target", "--finish"], + ], +) +def test_resumable_cli_rejects_windows_on_stderr(unsupported_platform, monkeypatch, command): + start = Mock() + finish = Mock() + monkeypatch.setattr(device_login, "start_connect_login", start) + monkeypatch.setattr(device_login, "start_cloud_login", start) + monkeypatch.setattr(device_login, "finish_login", finish) + result = cli_runner().invoke(main.cli, command) + assert result.exit_code == 1 + assert result.stdout == "" + assert "supported only on POSIX" in result.stderr + start.assert_not_called() + finish.assert_not_called() + + +@pytest.mark.parametrize("runtime", ["python", "nodejs"]) +def test_preflight_cli_rejects_windows_before_executor_access(unsupported_platform, monkeypatch, tmp_path, runtime): + executor = Mock() + monkeypatch.setattr(main, "RSConnectExecutor", executor) + result = cli_runner().invoke(main.cli, ["preflight", str(tmp_path), "--runtime", runtime]) + assert result.exit_code == 1 + report = json.loads(result.stdout) + assert report["status"] == "error" + assert report["runtime"] == runtime + assert "supported only on POSIX" in report["error"] + assert report["changed_files"] == [] + executor.assert_not_called() + + +@pytest.mark.parametrize("function", [preflight.run_preflight, preflight_node.run_node_preflight]) +def test_preflight_api_rejects_windows_before_project_access(unsupported_platform, function): + with pytest.raises(RSConnectException, match="supported only on POSIX"): + function(None, "/missing/project") + + +def test_existing_blocking_device_login_remains_available_on_windows(unsupported_platform, monkeypatch): + login = Mock(return_value={"access_token": "dummy-access", "refresh_token": "dummy-refresh"}) + monkeypatch.setattr(oauth, "discover_oauth_metadata", lambda *args: {}) + monkeypatch.setattr(oauth, "login_with_device_code", login) + monkeypatch.setattr(oauth, "keyring_store_token", lambda *args: False) + + result = cli_runner().invoke( + main.cli, ["login", "https://example.test", "--name", "target", "--client-id", "public", "--use-device-code"] + ) + + assert result.exit_code == 0, result.output + login.assert_called_once_with("https://example.test", "public", {}, False, None, open_browser=False) + assert main.server_store.get_by_name("target")["oauth_access_token"] == "dummy-access" diff --git a/tests/test_api.py b/tests/test_api.py index 97cbed9d..d435dddd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -24,12 +24,56 @@ class TestAPI(TestCase): + @httpretty.activate(allow_net_connect=False) + def test_nodejs_settings_preserves_runtime_flags(self): + settings = { + "enabled": True, + "installations": [{"version": "22.22.2", "publishable": False}], + "status": {"configured": True, "licensed": True}, + } + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/server_settings/nodejs", + body=json.dumps(settings), + content_type="application/json", + ) + client = RSConnectClient(RSConnectServer("http://test-server", "api_key")) + self.assertEqual(client.nodejs_settings(), settings) + self.assertEqual(httpretty.last_request().headers["Authorization"], "Key api_key") + + @httpretty.activate(allow_net_connect=False) + def test_nodejs_settings_reports_permission_failure(self): + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/v1/server_settings/nodejs", + status=403, + body=json.dumps({"error": "Forbidden"}), + content_type="application/json", + ) + client = RSConnectClient(RSConnectServer("http://test-server", "api_key")) + with self.assertRaises(RSConnectException): + client.nodejs_settings() + def test_executor_init(self): connect_server = require_connect() api_key = require_api_key() ce = RSConnectExecutor(url=connect_server, api_key=api_key, insecure=True) self.assertEqual(ce.remote_server.url, connect_server) + def test_executor_accepts_a_preloaded_app_store(self): + with patch("rsconnect.api.fake_module_file_from_directory", return_value="module.py"): + with patch("rsconnect.api.AppStore") as app_store: + default_executor = RSConnectExecutor(url="https://connect.example.com", api_key="key") + app_store.assert_called_once_with("module.py") + app_store.reset_mock() + preloaded_store = Mock() + injected_executor = RSConnectExecutor( + url="https://connect.example.com", api_key="key", app_store=preloaded_store + ) + app_store.assert_not_called() + self.assertIs(default_executor.app_store, app_store.return_value) + self.assertIs(injected_executor.app_store, preloaded_store) + def test_output_task_log(self): first_task = { "output": ["line 1", "line 2", "line 3"], @@ -154,7 +198,8 @@ def test_verify_api_key_connection_error(self): with patch.object(RSConnectClient, "get", return_value=failed_response): with self.assertRaises(RSConnectException) as cm: ce.verify_api_key() - self.assertIn("connection refused", str(cm.exception)) + self.assertEqual(str(cm.exception), "Could not connect to http://test-server/ - connection refused") + self.assertIs(cm.exception.cause, failed_response.exception) # The deprecated module-level verify_api_key() is reached via actions.test_api_key() # during `rsconnect add`, so it must accept the same credentials as the executor path. @@ -507,6 +552,7 @@ def test_exchange_token_error_status(self, mock_fmt_payload, mock_token_endpoint mock_server_instance = mock_http_server.return_value mock_response = Mock() mock_response.status = 401 + mock_response.exception = None mock_response.full_uri = "https://example.snowflakecomputing.com/oauth/token" mock_response.reason = "Unauthorized" mock_server_instance.request.return_value = mock_response @@ -520,8 +566,13 @@ def test_exchange_token_error_status(self, mock_fmt_payload, mock_token_endpoint } # Call the method and verify it raises the expected exception - with pytest.raises(RSConnectException, match="Failed to exchange Snowflake token"): + with pytest.raises(RSConnectException) as raised: server.exchange_token() + self.assertEqual( + raised.value.message, + "Failed to exchange Snowflake token: Received an unexpected response from " + "https://spcs.example.com (calling https://example.snowflakecomputing.com/oauth/token): 401 Unauthorized", + ) @patch("rsconnect.api.HTTPServer") @patch("rsconnect.api.SPCSConnectServer.token_endpoint") diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 34acd800..2585f7de 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -213,6 +213,38 @@ def test_refresh_requests_vivid_scope(self): self.assertEqual(kwargs["scope"], "vivid") self.assertEqual(kwargs["refresh_token"], "rt") + def test_refresh_can_limit_its_request_timeout(self): + with mock.patch("rsconnect.connect_cloud.refresh_access_token") as refresh: + refresh.return_value = {"access_token": "new"} + connect_cloud.refresh("rt", "production", request_timeout=0.25) + self.assertEqual(refresh.call_args.kwargs["request_timeout"], 0.25) + + def test_refresh_can_suppress_response_logging(self): + with mock.patch("rsconnect.connect_cloud.refresh_access_token") as refresh: + refresh.return_value = {"access_token": "new"} + connect_cloud.refresh("rt", "production", suppress_response_logging=True) + self.assertIs(refresh.call_args.kwargs["suppress_response_logging"], True) + + def test_refresh_honors_saved_client_override_and_deadline(self): + with mock.patch.dict( + os.environ, + {connect_cloud.OAUTH_CLIENT_ID_ENV_VAR: "current-client"}, + clear=True, + ): + with mock.patch("rsconnect.connect_cloud.refresh_access_token") as refresh: + refresh.return_value = {"access_token": "new"} + connect_cloud.refresh( + "rt", + "production", + request_timeout=0.25, + request_deadline=12.5, + client_id_override="pending-client", + ) + + self.assertEqual(refresh.call_args.kwargs["client_id"], "pending-client") + self.assertEqual(refresh.call_args.kwargs["request_timeout"], 0.25) + self.assertEqual(refresh.call_args.kwargs["request_deadline"], 12.5) + class TestConnectCloudServer(unittest.TestCase): def test_defaults_url_to_selected_environment(self): @@ -516,7 +548,11 @@ def test_get_account_by_name_reports_missing_account(self): with self.client: with self.assertRaises(RSConnectException) as context: self.client.get_account_by_name("acme") - self.assertIn("acme", str(context.exception)) + self.assertEqual( + context.exception.message, 'No Posit Connect Cloud account named "acme". You can publish to: other.' + ) + self.assertIs(type(context.exception), RSConnectException) + self.assertIsNone(context.exception.status) @httpretty.activate(verbose=True, allow_net_connect=False) def test_deleted_content_is_surfaced_as_404(self): @@ -775,6 +811,53 @@ def test_401_refreshes_with_refresh_token_and_retries_once(self): self.assertEqual(server.refresh_token, "rt2") self.assertEqual(httpretty.last_request().headers["Authorization"], "Bearer fresh") + def test_refresh_uses_the_remaining_finish_budget(self): + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", oauth_client_id="pending-client") + client = ConnectCloudClient(server) + client.request_timeout = 20 + client.request_deadline = 105 + with mock.patch.dict( + os.environ, + {connect_cloud.OAUTH_CLIENT_ID_ENV_VAR: "current-client"}, + clear=True, + ): + with mock.patch("rsconnect.api.time.monotonic", return_value=100): + with mock.patch("rsconnect.connect_cloud.refresh", return_value={"access_token": "fresh"}) as refresh: + self.assertTrue(client._attempt_token_refresh()) + refresh.assert_called_once_with( + "rt", + "production", + request_timeout=5, + request_deadline=105, + client_id_override="pending-client", + ) + self.assertEqual(server.access_token, "fresh") + + def test_private_refresh_suppresses_logging_and_limits_warning_details(self): + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt") + client = ConnectCloudClient(server) + client._suppress_oauth_response_logging = True + failure = RSConnectException("refresh response contained private details") + + with mock.patch("rsconnect.connect_cloud.refresh", side_effect=failure) as refresh: + with self.assertLogs("rsconnect", level="WARNING") as captured: + self.assertFalse(client._attempt_token_refresh()) + + refresh.assert_called_once_with("rt", "production", suppress_response_logging=True) + log_text = "\n".join(captured.output) + self.assertIn("RSConnectException", log_text) + self.assertNotIn("refresh response contained private details", log_text) + + def test_expired_finish_budget_does_not_refresh(self): + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt") + client = ConnectCloudClient(server) + client.request_deadline = 100 + with mock.patch("rsconnect.api.time.monotonic", return_value=100): + with mock.patch("rsconnect.connect_cloud.refresh") as refresh: + self.assertFalse(client._attempt_token_refresh()) + refresh.assert_not_called() + self.assertEqual(server.access_token, "stale") + @httpretty.activate(verbose=True, allow_net_connect=False) def test_401_uses_client_credentials_when_available(self): server = ConnectCloudServer("acme", access_token="stale", client_id="cid", client_secret="csecret") @@ -969,14 +1052,17 @@ def test_a_rejected_service_account_secret_is_reported_and_the_entry_is_left_alo def test_a_transient_refresh_failure_leaves_the_original_401_and_warns(self): self._save_entry(connect_cloud_access_token="stale", connect_cloud_refresh_token="rt") server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt", server_name="cloud") + secret = "refresh-token-secret" with self.assertLogs("rsconnect", level="WARNING") as captured: exception = self._get_user_with_refresh_failure( - server, "refresh", RSConnectException("Could not connect to https://login.posit.cloud") + server, "refresh", RSConnectException(f"Could not connect to https://login.posit.cloud: {secret}") ) self.assertIn("401", exception.message) - self.assertIn("token refresh failed", "\n".join(captured.output)) + log_text = "\n".join(captured.output) + self.assertIn("token refresh failed", log_text) + self.assertIn(secret, log_text) self.assertEqual(self._stored_entry()["connect_cloud_refresh_token"], "rt") self.assertEqual(len(httpretty.latest_requests()), 1) @@ -1665,8 +1751,8 @@ def test_unknown_account_lists_the_available_ones(self): self.client.get_account_by_name("typo") message = str(context.exception) - self.assertIn('No Posit Connect Cloud account named "typo"', message) - self.assertIn("alpha, beta", message) + self.assertEqual(message, 'No Posit Connect Cloud account named "typo". You can publish to: alpha, beta.') + self.assertIs(type(context.exception), RSConnectException) @httpretty.activate(verbose=True, allow_net_connect=False) def test_no_accounts_at_all(self): @@ -1674,19 +1760,74 @@ def test_no_accounts_at_all(self): with self.client: with self.assertRaises(RSConnectException) as context: self.client.get_account_by_name("anything") - self.assertIn("do not have publish access to any", str(context.exception)) + self.assertEqual( + context.exception.message, + 'No Posit Connect Cloud account named "anything". ' + "You do not have publish access to any Posit Connect Cloud accounts.", + ) + self.assertIs(type(context.exception), RSConnectException) @httpretty.activate(verbose=True, allow_net_connect=False) - def test_a_view_only_account_is_rejected(self): - # Without this the failure surfaces later as a raw error from POST /contents. - _register_accounts({"id": "1", "name": "acme", "permissions": ["content:read"]}) + def test_a_missing_record_account_lists_available_accounts(self): + _register_accounts({"id": "1", "name": "alpha"}, {"id": "2", "name": "beta"}) with self.client: with self.assertRaises(RSConnectException) as context: - self.client.get_account_by_name("acme") + self.client.get_account_by_id_or_name("stale-id") - message = str(context.exception) - self.assertIn('You have access to the Posit Connect Cloud account "acme"', message) - self.assertIn("do not have permission to publish to it", message) + self.assertEqual( + context.exception.message, + 'No Posit Connect Cloud account matching "stale-id", from this directory\'s deployment history. ' + "You can publish to: alpha, beta.", + ) + self.assertIs(type(context.exception), RSConnectException) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_missing_record_account_with_no_accounts_preserves_message(self): + _register_accounts() + with self.client: + with self.assertRaises(RSConnectException) as context: + self.client.get_account_by_id_or_name("stale-id") + + self.assertEqual( + context.exception.message, + 'No Posit Connect Cloud account matching "stale-id", from this directory\'s deployment history. ' + "You do not have publish access to any Posit Connect Cloud accounts.", + ) + self.assertIs(type(context.exception), RSConnectException) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_view_only_account_is_rejected_after_retrying_unauthorized_lookup(self): + # Without this the failure surfaces later as a raw error from POST /contents. + httpretty.register_uri( + httpretty.GET, + f"{API}/accounts", + responses=[ + httpretty.Response(body="", status=401), + _json_response({"data": [{"id": "1", "name": "acme", "permissions": ["content:read"]}], "total": 1}), + ], + ) + server = ConnectCloudServer("acme", access_token="stale", refresh_token="rt") + client = ConnectCloudClient(server) + + with mock.patch("rsconnect.connect_cloud.refresh", return_value={"access_token": "fresh"}) as refresh: + with client: + with self.assertRaises(RSConnectException) as context: + client.get_account_by_name("acme") + + refresh.assert_called_once_with("rt", "production") + self.assertEqual( + [request.headers["Authorization"] for request in httpretty.latest_requests()], + [ + "Bearer stale", + "Bearer fresh", + ], + ) + self.assertEqual( + context.exception.message, + 'You have access to the Posit Connect Cloud account "acme" but do not have ' + "permission to publish to it. Ask an account administrator for the publisher role.", + ) + self.assertIs(type(context.exception), RSConnectException) @httpretty.activate(verbose=True, allow_net_connect=False) def test_a_publishable_account_resolves(self): diff --git a/tests/test_device_login.py b/tests/test_device_login.py new file mode 100644 index 00000000..ac065387 --- /dev/null +++ b/tests/test_device_login.py @@ -0,0 +1,1372 @@ +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +import time +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import Mock + +import pytest + +from rsconnect import connect_cloud, device_login, oauth +from rsconnect.environment import fake_module_file_from_directory +from rsconnect.exception import ConnectCloudAccountNotFoundError, RSConnectException +from rsconnect.http_support import HTTPResponse +from rsconnect.metadata import AppStore, ServerStore +from rsconnect.models import AppModes + + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Agent login and preflight require POSIX.") + +SERVER = "https://connect.example.com" +METADATA = { + "token_endpoint": SERVER + "/oauth/token", + "device_authorization_endpoint": SERVER + "/oauth/device", + "registration_endpoint": SERVER + "/oauth/register", +} +DEVICE_RESPONSE = { + "device_code": "device-code-secret", + "user_code": "ABCD-EFGH", + "verification_uri": SERVER + "/activate", + "expires_in": 600, + "interval": 5, +} + + +def _response(status: int, data: dict[str, Any]) -> HTTPResponse: + response = HTTPResponse("", body=b"") + response.status = status + response.json_data = data + return response + + +class _Clock: + def __init__(self) -> None: + self.wall = 1_700_000_000.0 + self.elapsed = 0.0 + + def time(self) -> float: + return self.wall + + def monotonic(self) -> float: + return self.elapsed + + def advance(self, seconds: float) -> None: + self.wall += seconds + self.elapsed += seconds + + def sleep(self, seconds: float) -> None: + self.advance(seconds) + + +@pytest.fixture +def login_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any: + clock = _Clock() + monkeypatch.setattr(device_login, "config_dirname", lambda: str(tmp_path)) + monkeypatch.setattr(device_login, "time", clock) + + class HTTPBoundary: + responses: list[Any] = [] + instances: list[Any] = [] + + def __init__( + self, + url: str, + disable_tls_check: bool = False, + ca_data: Any = None, + request_timeout: float | None = None, + request_deadline: float | None = None, + **kwargs: Any, + ) -> None: + self.url = url + self.disable_tls_check = disable_tls_check + self.ca_data = ca_data + self.request_timeout = request_timeout + self.request_deadline = request_deadline + self.entered_timeout = None + self.entered_deadline = None + + def __enter__(self) -> Any: + self.entered_timeout = self.request_timeout + self.entered_deadline = self.request_deadline + self.instances.append(self) + return self + + def __exit__(self, *args: Any) -> bool: + return False + + def request(self, method: str, path: str, **kwargs: Any) -> Any: + self.calls = getattr(self, "calls", []) + self.calls.append((method, path, kwargs)) + result = self.responses.pop(0) + if isinstance(result, BaseException): + raise result + return result + + def get(self, path: str, **kwargs: Any) -> Any: + return self.request("GET", path, **kwargs) + + def post(self, path: str, **kwargs: Any) -> Any: + return self.request("POST", path, **kwargs) + + monkeypatch.setattr(oauth, "HTTPServer", HTTPBoundary) + return SimpleNamespace(path=tmp_path, clock=clock, http=HTTPBoundary) + + +def _stub_connect_discovery(monkeypatch: pytest.MonkeyPatch, metadata: dict[str, Any] | None = None) -> None: + monkeypatch.setattr(device_login, "discover_oauth_metadata", lambda *args, **kwargs: metadata or METADATA) + monkeypatch.setattr(device_login, "register_client", lambda *args, **kwargs: "registered-client") + + +def _queue_device(login_env: Any, response: dict[str, Any] | HTTPResponse | None = None) -> None: + login_env.http.responses.append(response or _response(200, DEVICE_RESPONSE)) + + +def _state_path(kind: str, name: str) -> Path: + return Path(device_login._state_path(kind, name)) + + +def test_connect_start_uses_oauth_helpers_with_one_bounded_deadline(login_env: Any, monkeypatch: pytest.MonkeyPatch): + login_env.http.responses.extend( + [ + _response(200, METADATA), + _response(200, {"client_id": "registered-client"}), + _response(200, DEVICE_RESPONSE), + ] + ) + discover = oauth.discover_oauth_metadata + register = oauth.register_client + + def discover_then_advance(*args: Any, **kwargs: Any) -> dict[str, Any]: + result = discover(*args, **kwargs) + login_env.clock.advance(12) + return result + + def register_then_advance(*args: Any, **kwargs: Any) -> str: + result = register(*args, **kwargs) + login_env.clock.advance(7) + return result + + monkeypatch.setattr(oauth, "HTTPServer", login_env.http) + monkeypatch.setattr(device_login, "discover_oauth_metadata", discover_then_advance) + monkeypatch.setattr(device_login, "register_client", register_then_advance) + + result = device_login.start_connect_login(SERVER + "/proxy/__api__?tab=login#top", "work") + + assert result == { + "status": "pending", + "name": "work", + "server": SERVER + "/proxy", + "verification_uri": SERVER + "/activate", + "user_code": "ABCD-EFGH", + "expires_in": 600, + } + assert [client.entered_timeout for client in login_env.http.instances] == [120, 108, 101] + assert [client.entered_deadline for client in login_env.http.instances] == [120, 120, 120] + assert [client.calls[0][1] for client in login_env.http.instances] == [ + "/.well-known/oauth-authorization-server", + "/oauth/register", + "/oauth/device", + ] + state_file = _state_path("connect", "work") + assert state_file.exists() + assert stat.S_IMODE(state_file.stat().st_mode) == 0o600 + + +def test_start_and_finish_share_form_transport_and_keep_endpoint_queries( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + metadata = { + **METADATA, + "device_authorization_endpoint": SERVER + "/oauth/device?tenant=acme", + "token_endpoint": SERVER + "/oauth/token?tenant=acme", + } + _stub_connect_discovery(monkeypatch, metadata) + monkeypatch.setattr(device_login, "keyring_store_token", lambda *args: False) + _queue_device(login_env) + + device_login.start_connect_login(SERVER, "work", client_id="client") + login_env.clock.advance(5) + login_env.http.responses.extend( + [ + _response(400, {"error": "authorization_pending"}), + _response(200, {"access_token": "approved-access", "refresh_token": "approved-refresh"}), + ] + ) + + result = device_login.finish_login("connect", "work", timeout=30) + + assert result == {"status": "done", "name": "work", "server": SERVER} + assert [client.calls[0][1] for client in login_env.http.instances] == [ + "/oauth/device?tenant=acme", + "/oauth/token?tenant=acme", + "/oauth/token?tenant=acme", + ] + assert login_env.http.instances[0].entered_deadline == 120 + assert login_env.http.instances[1].entered_deadline == 35 + assert login_env.http.instances[2].entered_deadline == 35 + + +@pytest.mark.parametrize("kind", ["connect", "cloud"]) +@pytest.mark.parametrize("elapsed", [119.99, 120, 120.01]) +def test_start_rechecks_deadline_after_complete_authorization( + login_env: Any, monkeypatch: pytest.MonkeyPatch, kind: str, elapsed: float +): + _stub_connect_discovery(monkeypatch) + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + _queue_device(login_env) + original_request = device_login._start_device_request + + def completed_request(*args: Any, **kwargs: Any): + result = original_request(*args, **kwargs) + login_env.clock.advance(elapsed) + return result + + monkeypatch.setattr(device_login, "_start_device_request", completed_request) + + def start(): + if kind == "connect": + return device_login.start_connect_login(SERVER, "work") + return device_login.start_cloud_login("team", "work") + + if elapsed >= 120: + with pytest.raises(RSConnectException, match="start exceeded its 120-second limit"): + start() + assert device_login._read_state(kind, "work") is None + else: + assert start()["status"] == "pending" + assert device_login._read_state(kind, "work")["device_code"] == "device-code-secret" + + +def test_same_target_reuses_code_and_other_target_is_rejected(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + first = device_login.start_connect_login(SERVER + "/proxy/__api__", "work") + resumed = device_login.start_connect_login(SERVER.upper().replace("HTTPS", "https") + "/proxy#again", "work") + + assert resumed["user_code"] == first["user_code"] + assert len(login_env.http.instances) == 1 + with pytest.raises(RSConnectException, match="different target"): + device_login.start_connect_login("https://elsewhere.example.com", "work") + + +def test_connect_path_named_connect_is_preserved(login_env: Any, monkeypatch: pytest.MonkeyPatch): + discovered: list[str] = [] + monkeypatch.setattr( + device_login, + "discover_oauth_metadata", + lambda server, *args, **kwargs: discovered.append(server) or METADATA, + ) + monkeypatch.setattr(device_login, "register_client", lambda *args, **kwargs: "registered-client") + _queue_device(login_env) + + result = device_login.start_connect_login(SERVER + "/connect", "work") + + assert discovered == [SERVER + "/connect"] + assert result["server"] == SERVER + "/connect" + + +def test_live_state_is_scoped_by_kind_and_name(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + connect = device_login.start_connect_login(SERVER, "same") + + login_env.http.responses.append(_response(200, DEVICE_RESPONSE)) + cloud = device_login.start_cloud_login("team", "same") + + assert connect["server"] == SERVER + assert cloud["server"] == connect_cloud.urls("production").api + assert _state_path("connect", "same") != _state_path("cloud", "same") + assert device_login._read_state("connect", "same")["kind"] == "connect" + assert device_login._read_state("cloud", "same")["kind"] == "cloud" + + +@pytest.mark.parametrize("kind", ["connect", "cloud"]) +def test_start_reuses_expired_checkpoint_and_preserves_target( + login_env: Any, monkeypatch: pytest.MonkeyPatch, kind: str +): + _stub_connect_discovery(monkeypatch) + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + _queue_device(login_env) + + def start(): + if kind == "connect": + return device_login.start_connect_login(SERVER, "work") + return device_login.start_cloud_login("team", "work") + + start() + state = device_login._read_state(kind, "work") + state["tokens"] = { + "access_token": "checkpointed-access", + "refresh_token": "checkpointed-refresh", + "expires_at": login_env.clock.time() + 3600, + } + device_login._write_state(state) + login_env.clock.advance(601) + _queue_device(login_env, _response(200, {**DEVICE_RESPONSE, "user_code": "NEW-CODE"})) + + result = start() + + assert result["user_code"] == "ABCD-EFGH" + assert result["expires_in"] == 0 + assert device_login._read_state(kind, "work")["tokens"] == state["tokens"] + assert len(login_env.http.instances) == 1 + with pytest.raises(RSConnectException, match="different target"): + if kind == "connect": + device_login.start_connect_login("https://elsewhere.example.com", "work") + else: + device_login.start_cloud_login("other-team", "work") + assert device_login._read_state(kind, "work")["tokens"] == state["tokens"] + + +@pytest.mark.parametrize("kind", ["connect", "cloud"]) +def test_start_replaces_expired_code_without_checkpointed_tokens( + login_env: Any, monkeypatch: pytest.MonkeyPatch, kind: str +): + _stub_connect_discovery(monkeypatch) + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + _queue_device(login_env) + + def start(): + if kind == "connect": + return device_login.start_connect_login(SERVER, "work") + return device_login.start_cloud_login("team", "work") + + start() + login_env.clock.advance(601) + _queue_device(login_env, _response(200, {**DEVICE_RESPONSE, "user_code": "NEW-CODE"})) + + result = start() + + assert result["user_code"] == "NEW-CODE" + assert result["expires_in"] == 600 + assert device_login._read_state(kind, "work")["tokens"] is None + assert len(login_env.http.instances) == 2 + + +def test_saved_nickname_cannot_be_retargeted(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + device_login._store().set("work", SERVER, oauth_client_id="saved-client") + + with pytest.raises(RSConnectException, match="already saved for a different target"): + device_login.start_connect_login("https://other.example.com", "work") + assert not login_env.http.instances + + +def test_saved_non_connect_credential_is_not_reused_for_connect(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + device_login._store().set("work", SERVER, account_name="analyst", token="t", secret="s") + + with pytest.raises(RSConnectException, match="different target"): + device_login.start_connect_login(SERVER, "work") + assert not login_env.http.instances + + +def test_saved_tls_and_client_id_are_reused(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + device_login._store().set("work", SERVER, oauth_client_id="saved-client", insecure=True) + _queue_device(login_env) + + device_login.start_connect_login(SERVER, "work") + + assert login_env.http.instances[-1].disable_tls_check is True + assert device_login._read_state("connect", "work")["client_id"] == "saved-client" + + +@pytest.mark.parametrize("name", ["work", "alias"]) +@pytest.mark.parametrize( + "saved_url", + [ + SERVER + "/", + "https://CONNECT.example.com:443", + SERVER + "?tab=login", + SERVER + "/__api__", + SERVER + "?refresh_token=url-query-secret#access_token=url-fragment-secret", + ], +) +def test_connect_finish_preserves_saved_url_key_and_deployment_history( + login_env: Any, monkeypatch: pytest.MonkeyPatch, name: str, saved_url: str +): + _stub_connect_discovery(monkeypatch) + keyring_save = Mock(return_value=False) + monkeypatch.setattr(device_login, "keyring_store_token", keyring_save) + store = device_login._store() + store.set("work", saved_url, oauth_client_id="saved-client") + project = login_env.path / "project" + project.mkdir() + app_file = fake_module_file_from_directory(str(project)) + history = AppStore(app_file) + history.set(saved_url, app_file, SERVER + "/content/existing", "existing-id", None, "Existing", "python-api") + original_history = Path(history.get_path()).read_bytes() + _queue_device(login_env) + device_login.start_connect_login(SERVER, name) + login_env.clock.advance(5) + login_env.http.responses.append({"access_token": "approved-access", "refresh_token": "approved-refresh"}) + + result = device_login.finish_login("connect", name) + + saved = device_login._store().get_by_name(name) + assert result == {"status": "done", "name": name, "server": SERVER} + assert saved["url"] == saved_url + assert saved["oauth_access_token"] == "approved-access" + keyring_save.assert_called_once_with(saved_url, "approved-access", "approved-refresh") + assert history.resolve(saved["url"], None, AppModes.get_by_name("python-api"))[0] == "existing-id" + assert Path(history.get_path()).read_bytes() == original_history + assert not _state_path("connect", name).exists() + + +@pytest.mark.parametrize( + ("saved_tls", "explicit_tls", "expected_insecure", "expected_ca"), + [ + ({"insecure": True}, {"ca_data": b"CERT"}, False, b"CERT"), + ({"insecure": False, "ca_data": "saved-ca"}, {"insecure": True}, True, None), + ], +) +def test_explicit_tls_replaces_saved_tls( + login_env: Any, + monkeypatch: pytest.MonkeyPatch, + saved_tls: dict[str, Any], + explicit_tls: dict[str, Any], + expected_insecure: bool, + expected_ca: str | bytes | None, +): + _stub_connect_discovery(monkeypatch) + device_login._store().set( + "work", + SERVER, + oauth_client_id="saved-client", + insecure=saved_tls.get("insecure"), + ca_data=saved_tls.get("ca_data"), + ) + _queue_device(login_env) + + device_login.start_connect_login(SERVER, "work", **explicit_tls) + + discovery = login_env.http.instances[0] + device_request = login_env.http.instances[-1] + assert discovery.disable_tls_check is expected_insecure + assert discovery.ca_data == expected_ca + assert device_request.disable_tls_check is expected_insecure + assert device_request.ca_data == expected_ca + state = device_login._read_state("connect", "work") + assert state["client_id"] == "saved-client" + + +@pytest.mark.parametrize( + ("metadata_change", "device_change"), + [ + ({"device_authorization_endpoint": "http://connect.example.com/device"}, {}), + ({}, {"verification_uri": "http://attacker.example/approve"}), + ], +) +def test_https_target_rejects_http_oauth_or_verification_uri( + login_env: Any, + monkeypatch: pytest.MonkeyPatch, + metadata_change: dict[str, Any], + device_change: dict[str, Any], +): + _stub_connect_discovery(monkeypatch, {**METADATA, **metadata_change}) + _queue_device(login_env, _response(200, {**DEVICE_RESPONSE, **device_change})) + + with pytest.raises(RSConnectException, match="HTTPS"): + device_login.start_connect_login(SERVER, "work") + assert not _state_path("connect", "work").exists() + + +@pytest.mark.parametrize("url", ["https://user@connect.example.com", "https://connect.example.com/\npath"]) +def test_server_url_rejects_userinfo_and_control_characters(login_env: Any, monkeypatch: pytest.MonkeyPatch, url: str): + _stub_connect_discovery(monkeypatch) + + with pytest.raises(RSConnectException): + device_login.start_connect_login(url, "work") + assert not login_env.http.instances + + +def test_invalid_client_retries_registration_once(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + registered: list[dict[str, Any]] = [] + monkeypatch.setattr( + device_login, + "register_client", + lambda *args, **kwargs: registered.append(kwargs) or "replacement-client", + ) + login_env.http.responses.extend( + [ + _response(401, {"error": "invalid_client", "error_description": "do not reveal this"}), + _response(200, DEVICE_RESPONSE), + ] + ) + + result = device_login.start_connect_login(SERVER, "work", client_id="old-client") + + assert result["status"] == "pending" + assert len(registered) == 1 + assert len(login_env.http.instances) == 2 + assert device_login._read_state("connect", "work")["client_id"] == "replacement-client" + + +@pytest.mark.parametrize("source", ["explicit", "saved", "registered"]) +def test_invalid_client_id_never_persists_pending_state(login_env: Any, monkeypatch: pytest.MonkeyPatch, source: str): + _stub_connect_discovery(monkeypatch) + options: dict[str, Any] = {} + if source == "explicit": + options["client_id"] = "invalid\nclient" + elif source == "saved": + device_login._store().set("work", SERVER, oauth_client_id="invalid\nclient") + else: + monkeypatch.setattr(device_login, "register_client", lambda *args, **kwargs: "invalid\nclient") + + with pytest.raises(RSConnectException, match="OAuth client ID"): + device_login.start_connect_login(SERVER, "work", **options) + + assert not _state_path("connect", "work").exists() + assert not login_env.http.instances + + +@pytest.mark.parametrize("field", ["interval", "expires_in"]) +def test_non_finite_device_timing_is_rejected_before_saving( + login_env: Any, monkeypatch: pytest.MonkeyPatch, field: str +): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env, _response(200, {**DEVICE_RESPONSE, field: 10**1000})) + + with pytest.raises(RSConnectException, match="invalid expiry or interval"): + device_login.start_connect_login(SERVER, "work", client_id="client") + + assert not _state_path("connect", "work").exists() + + +def test_read_state_rejects_unrepresentable_poll_interval(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work", client_id="client") + state = device_login._read_state("connect", "work") + state["interval"] = 10**1000 + device_login._write_state(state) + + with pytest.raises(RSConnectException, match="Pending device login state is invalid"): + device_login._read_state("connect", "work") + + +def test_start_error_does_not_echo_server_description(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + login_env.http.responses.append( + _response(400, {"error": "invalid_request", "error_description": "device-code-secret"}) + ) + + with pytest.raises(RSConnectException) as raised: + device_login.start_connect_login(SERVER, "work", client_id="client") + + assert "device-code-secret" not in str(raised.value) + assert not _state_path("connect", "work").exists() + + +def test_finish_pending_hides_secrets_and_slow_down_survives_calls(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(5) + login_env.http.responses.append(_response(400, {"error": "slow_down"})) + + first = device_login.finish_login("connect", "work", timeout=1) + second = device_login.finish_login("connect", "work", timeout=4) + state = device_login._read_state("connect", "work") + + assert first == {"status": "pending", "name": "work", "server": SERVER} + assert second == first + assert state["interval"] == 10 + assert len(login_env.http.instances) == 2 # one authorization request and one token poll + assert login_env.http.instances[-1].entered_timeout == 1 + assert not {"device_code", "access_token", "refresh_token"} & first.keys() + + +def test_authorization_pending_persists_poll_time_before_immediate_retry( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(5) + poll_time = login_env.clock.time() + login_env.http.responses.extend( + [ + _response(400, {"error": "authorization_pending"}), + {"access_token": "approved-access", "expires_in": 3600}, + ] + ) + + first = device_login.finish_login("connect", "work", timeout=1) + checkpoint = device_login._read_state("connect", "work") + + assert first == {"status": "pending", "name": "work", "server": SERVER} + assert checkpoint["last_poll_at"] == poll_time + assert checkpoint["interval"] == 5 + assert len(login_env.http.instances) == 2 + + second = device_login.finish_login("connect", "work", timeout=4) + checkpoint = device_login._read_state("connect", "work") + + assert second == first + assert checkpoint["last_poll_at"] == poll_time + assert len(login_env.http.instances) == 2 + + +def test_poll_failure_after_finish_deadline_returns_pending_without_retrying_early( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(5) + attempts = 0 + + def fail_after_deadline(*args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + request_timeout = args[-1] + login_env.clock.advance(request_timeout + 0.25) + raise RSConnectException("temporary network failure") + + monkeypatch.setattr(device_login, "_post_form", fail_after_deadline) + + result = device_login.finish_login("connect", "work", timeout=1) + checkpoint = device_login._read_state("connect", "work") + + assert result == {"status": "pending", "name": "work", "server": SERVER} + assert checkpoint["last_poll_at"] == login_env.clock.time() + assert attempts == 1 + + result = device_login.finish_login("connect", "work", timeout=1) + assert result == {"status": "pending", "name": "work", "server": SERVER} + assert attempts == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ("access_denied", "denied"), + ("expired_token", "expired"), + ("invalid_client", "invalid"), + ("invalid_grant", "invalid"), + ], +) +def test_terminal_poll_errors_remove_state(login_env: Any, monkeypatch: pytest.MonkeyPatch, error: str, message: str): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.http.responses.append(_response(400, {"error": error, "error_description": "access-token-secret"})) + + with pytest.raises(RSConnectException, match=message) as raised: + device_login.finish_login("connect", "work") + + assert "access-token-secret" not in str(raised.value) + assert not _state_path("connect", "work").exists() + + +def test_expired_pending_state_is_removed(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(601) + + with pytest.raises(RSConnectException, match="expired"): + device_login.finish_login("connect", "work") + assert not _state_path("connect", "work").exists() + + +@pytest.mark.skipif(os.name != "posix", reason="owner-only mode bits are POSIX-specific") +def test_pending_state_with_permissive_mode_is_rejected_before_poll(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + os.chmod(_state_path("connect", "work"), 0o644) + + with pytest.raises(RSConnectException, match="owner-only"): + device_login.finish_login("connect", "work") + assert len(login_env.http.instances) == 1 + + +def test_symlink_pending_state_is_rejected_before_poll(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + state_file = _state_path("connect", "work") + target = login_env.path / "linked-state.json" + target.write_bytes(state_file.read_bytes()) + state_file.unlink() + try: + state_file.symlink_to(target) + except OSError as exc: + pytest.skip("symlink creation is unavailable: %s" % exc) + + with pytest.raises(RSConnectException, match="safely read"): + device_login.finish_login("connect", "work") + assert len(login_env.http.instances) == 1 + + +@pytest.mark.skipif(os.name != "posix", reason="owner IDs are POSIX-specific") +def test_pending_state_owned_by_another_user_is_rejected(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + original_fstat = os.fstat + + def other_owner(descriptor: int) -> Any: + info = original_fstat(descriptor) + return SimpleNamespace( + st_mode=info.st_mode, + st_uid=info.st_uid + 1, + st_dev=info.st_dev, + st_ino=info.st_ino, + ) + + monkeypatch.setattr(device_login.os, "fstat", other_owner) + with pytest.raises(RSConnectException, match="owner-only"): + device_login._read_state("connect", "work") + + +@pytest.mark.skipif(os.name != "posix", reason="directory permission bits are POSIX-specific") +def test_new_state_directory_is_private_and_existing_config_permissions_are_preserved( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + new_config = login_env.path / "new-config" + monkeypatch.setattr(device_login, "config_dirname", lambda: str(new_config)) + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "private") + assert stat.S_IMODE(new_config.stat().st_mode) == 0o700 + + existing_config = login_env.path / "existing-config" + existing_config.mkdir(mode=0o755) + os.chmod(existing_config, 0o755) + monkeypatch.setattr(device_login, "config_dirname", lambda: str(existing_config)) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "existing") + assert stat.S_IMODE(existing_config.stat().st_mode) == 0o755 + assert stat.S_IMODE(_state_path("connect", "existing").stat().st_mode) == 0o600 + + +def test_start_removes_only_orphaned_temps_for_the_same_login(login_env: Any, monkeypatch: pytest.MonkeyPatch): + orphan = login_env.path / (device_login._temporary_state_prefix("connect", "work") + "interrupted") + other_login = login_env.path / (device_login._temporary_state_prefix("connect", "other") + "active") + orphan.write_text("secret device state", encoding="utf-8") + other_login.write_text("other active secret", encoding="utf-8") + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + + device_login.start_connect_login(SERVER, "work") + + assert not orphan.exists() + assert other_login.read_text(encoding="utf-8") == "other active secret" + + +def test_interrupted_state_write_removes_its_secret_temp(login_env: Any, monkeypatch: pytest.MonkeyPatch): + def interrupt(*args: Any, **kwargs: Any) -> None: + raise KeyboardInterrupt() + + monkeypatch.setattr(device_login.json, "dump", interrupt) + with pytest.raises(KeyboardInterrupt): + device_login._write_state({"kind": "connect", "name": "work", "device_code": "secret"}) + + assert not list(login_env.path.glob(device_login._temporary_state_prefix("connect", "work") + "*")) + + +def test_replace_error_is_wrapped_and_preserves_previous_state(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + state_file = _state_path("connect", "work") + original = state_file.read_bytes() + state = device_login._read_state("connect", "work") + state["set_default"] = not state["set_default"] + + def fail_replace(*args: Any, **kwargs: Any) -> None: + raise PermissionError("destination is temporarily locked") + + monkeypatch.setattr(device_login.os, "replace", fail_replace) + with pytest.raises(RSConnectException, match="safely write"): + device_login._write_state(state) + + assert state_file.read_bytes() == original + assert not list(login_env.path.glob(device_login._temporary_state_prefix("connect", "work") + "*")) + + +def test_state_reader_does_not_close_a_reused_descriptor(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + original_fdopen = os.fdopen + replacement_file = login_env.path / "replacement" + replacement_file.write_bytes(b"ok") + opened = [] + + @contextmanager + def fdopen_with_concurrent_open(*args, **kwargs): + with original_fdopen(*args, **kwargs) as stream: + yield stream + opened.append(os.open(str(replacement_file), os.O_RDONLY)) + + monkeypatch.setattr(device_login.os, "fdopen", fdopen_with_concurrent_open) + try: + assert device_login._read_state("connect", "work")["name"] == "work" + assert os.read(opened[0], 2) == b"ok" + finally: + for descriptor in opened: + os.close(descriptor) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="platform does not support FIFOs") +def test_pending_fifo_is_rejected_without_waiting_for_a_writer(login_env: Any): + fifo = _state_path("connect", "work") + os.mkfifo(str(fifo), mode=0o600) + script = """ +import sys +from rsconnect import device_login +from rsconnect.exception import RSConnectException +device_login.config_dirname = lambda: sys.argv[1] +try: + device_login.finish_login("connect", "work") +except RSConnectException as error: + print(str(error)) +""" + result = subprocess.run( + [sys.executable, "-c", script, str(login_env.path)], capture_output=True, text=True, timeout=5 + ) + assert result.returncode == 0, result.stderr + assert "not a regular file" in result.stdout + + +def test_start_and_finish_share_a_bounded_cross_process_lock(tmp_path: Path): + marker = tmp_path / "lock-held" + holder_script = """ +import sys +import time +from pathlib import Path +from rsconnect import device_login +device_login.config_dirname = lambda: sys.argv[1] +with device_login._state_lock("connect", "work", time.monotonic() + 10): + Path(sys.argv[2]).write_text("locked") + time.sleep(10) +""" + holder = subprocess.Popen( + [sys.executable, "-c", holder_script, str(tmp_path), str(marker)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + ready_by = time.monotonic() + 5 + while not marker.exists() and holder.poll() is None and time.monotonic() < ready_by: + time.sleep(0.01) + assert marker.exists(), "lock holder did not acquire the state lock" + + finish_script = """ +import json, sys +from rsconnect import device_login +device_login.config_dirname = lambda: sys.argv[1] +print(json.dumps(device_login.finish_login("connect", "work", timeout=1))) +""" + result = subprocess.run( + [sys.executable, "-c", finish_script, str(tmp_path)], + capture_output=True, + text=True, + timeout=2, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"status": "pending", "name": "work", "server": None} + + start_script = """ +import sys +from rsconnect import device_login +from rsconnect.exception import RSConnectException +device_login.config_dirname = lambda: sys.argv[1] +device_login._STATE_LOCK_TIMEOUT = 1 +def unexpected_discovery(*args, **kwargs): + raise AssertionError("start performed OAuth I/O while the state lock was busy") +device_login.discover_oauth_metadata = unexpected_discovery +try: + device_login.start_connect_login("https://connect.example.com", "work") +except RSConnectException as error: + print(str(error)) +""" + result = subprocess.run( + [sys.executable, "-c", start_script, str(tmp_path)], + capture_output=True, + text=True, + timeout=2, + ) + assert result.returncode == 0, result.stderr + assert "operation is in progress" in result.stdout + finally: + holder.terminate() + holder.wait(timeout=5) + + +@pytest.mark.parametrize( + ("key", "value"), + [("version", "2"), ("last_poll_at", "not-a-time"), ("expires_at", 10**400)], +) +def test_corrupt_state_schema_is_rejected_before_poll( + login_env: Any, + monkeypatch: pytest.MonkeyPatch, + key: str, + value: Any, +): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + state_file = _state_path("connect", "work") + state = json.loads(state_file.read_text()) + state[key] = value + state_file.write_text(json.dumps(state)) + + with pytest.raises(RSConnectException, match="state"): + device_login.finish_login("connect", "work") + assert len(login_env.http.instances) == 1 + + +@pytest.mark.parametrize("failure", ["transport", "server"]) +def test_failed_poll_still_enforces_the_interval_on_an_immediate_retry( + login_env: Any, monkeypatch: pytest.MonkeyPatch, failure: str +): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(5) + response = ( + HTTPResponse("", exception=TimeoutError("temporary connection failure")) + if failure == "transport" + else _response(503, {"error": "server_error"}) + ) + login_env.http.responses.append(response) + + with pytest.raises(RSConnectException): + device_login.finish_login("connect", "work") + assert device_login._read_state("connect", "work")["last_poll_at"] == login_env.clock.time() + + result = device_login.finish_login("connect", "work", timeout=1) + assert result["status"] == "pending" + assert len(login_env.http.instances) == 2 + + login_env.clock.advance(4) + login_env.http.responses.append({"access_token": "approved-access", "expires_in": 3600}) + result = device_login.finish_login("connect", "work", timeout=1) + assert result["status"] == "done" + assert len(login_env.http.instances) == 3 + + +def test_transient_network_failure_keeps_state_and_token_error_does_not_leak( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.http.responses.extend( + [ + HTTPResponse("", exception=TimeoutError("access-token-secret")), + _response(400, {"error": "server_error", "error_description": "access-token-secret"}), + ] + ) + + with pytest.raises(RSConnectException) as raised: + device_login.finish_login("connect", "work") + assert "access-token-secret" not in str(raised.value) + assert _state_path("connect", "work").exists() + + with pytest.raises(RSConnectException) as raised: + device_login.finish_login("connect", "work") + assert "access-token-secret" not in str(raised.value) + assert _state_path("connect", "work").exists() + + +def test_connect_token_checkpoint_resumes_store_failure(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(5) + login_env.http.responses.append( + {"access_token": "access-secret", "refresh_token": "refresh-secret", "expires_in": 3600} + ) + monkeypatch.setattr(device_login, "keyring_store_token", lambda *args: False) + original_set = ServerStore.set + attempts = 0 + + def fail_once(store: ServerStore, *args: Any, **kwargs: Any) -> Any: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise OSError("temporary store failure") + return original_set(store, *args, **kwargs) + + monkeypatch.setattr(ServerStore, "set", fail_once) + with pytest.raises(OSError, match="temporary store failure"): + device_login.finish_login("connect", "work") + checkpoint = device_login._read_state("connect", "work") + assert checkpoint["tokens"]["access_token"] == "access-secret" + assert stat.S_IMODE(_state_path("connect", "work").stat().st_mode) == 0o600 + + login_env.clock.advance(601) + resumed = device_login.start_connect_login(SERVER, "work") + assert resumed["user_code"] == "ABCD-EFGH" + assert device_login._read_state("connect", "work")["tokens"]["access_token"] == "access-secret" + result = device_login.finish_login("connect", "work") + saved = device_login._store().get_by_name("work") + assert result["status"] == "done" + assert saved["oauth_access_token"] == "access-secret" + assert saved["oauth_refresh_token"] == "refresh-secret" + assert device_login._store().get_default()["name"] == "work" + assert len(login_env.http.instances) == 2 + assert not _state_path("connect", "work").exists() + + +@pytest.mark.parametrize( + "expires_in", + [float("inf"), float("-inf"), float("nan"), True, False, "invalid", 10**400], +) +def test_unusable_token_expiry_keeps_approved_tokens(login_env: Any, monkeypatch: pytest.MonkeyPatch, expires_in: Any): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work") + login_env.clock.advance(5) + login_env.http.responses.append( + {"access_token": "approved-access", "refresh_token": "approved-refresh", "expires_in": expires_in} + ) + monkeypatch.setattr(device_login, "keyring_store_token", lambda *args: False) + + result = device_login.finish_login("connect", "work") + + saved = device_login._store().get_by_name("work") + assert result["status"] == "done" + assert saved["oauth_access_token"] == "approved-access" + assert saved["oauth_refresh_token"] == "approved-refresh" + assert saved.get("oauth_token_expiry") is None + + +def test_bytes_ca_is_persisted_and_reused_during_finish(login_env: Any, monkeypatch: pytest.MonkeyPatch): + _stub_connect_discovery(monkeypatch) + _queue_device(login_env) + device_login.start_connect_login(SERVER, "work", ca_data=b"CERTIFICATE") + state = device_login._read_state("connect", "work") + assert state["ca_data"] is None + assert state["ca_data_b64"] == "Q0VSVElGSUNBVEU=" + + login_env.clock.advance(5) + login_env.http.responses.append({"access_token": "access", "expires_in": 60}) + monkeypatch.setattr(device_login, "keyring_store_token", lambda *args: False) + device_login.finish_login("connect", "work") + + assert login_env.http.instances[-1].ca_data == b"CERTIFICATE" + assert device_login._store().get_by_name("work")["ca_cert"] == "CERTIFICATE" + + +@pytest.mark.parametrize( + ("lookup_results", "save_failures", "expected_error", "expected_message"), + [ + ( + [RSConnectException("temporary account lookup failure"), {"id": "team-id"}], + [], + RSConnectException, + "account lookup failed", + ), + ( + [ + RSConnectException( + "Rejected cloud-access-secret", status=200, cause=OSError("original transport error") + ), + {"id": "team-id"}, + ], + [], + RSConnectException, + r"account lookup failed \(HTTP 200\)", + ), + ( + [ + RSConnectException( + "Rejected cloud-access-secret", status=503, cause=OSError("original transport error") + ), + {"id": "team-id"}, + ], + [], + RSConnectException, + r"account lookup failed \(HTTP 503\)", + ), + ( + [{"id": "team-id"}, {"id": "team-id"}], + [OSError("temporary store failure")], + OSError, + "temporary store failure", + ), + ], +) +def test_cloud_checkpoint_retries_permission_lookup_or_save( + login_env: Any, + monkeypatch: pytest.MonkeyPatch, + lookup_results: list[Any], + save_failures: list[Any], + expected_error: type[Exception], + expected_message: str, +): + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + login_env.http.responses.append(_response(200, DEVICE_RESPONSE)) + result = device_login.start_cloud_login("team", "cloud-login") + login_env.clock.advance(5) + login_env.http.responses.append( + {"access_token": "cloud-access-secret", "refresh_token": "cloud-refresh-secret", "expires_in": 3600} + ) + clients: list[Any] = [] + account_results = iter(lookup_results) + + class CloudServer: + def __init__(self, **kwargs: Any) -> None: + self.access_token = kwargs["access_token"] + self.refresh_token = kwargs["refresh_token"] + + class CloudClient: + def __init__(self, server: Any) -> None: + self.server = server + self.request_timeout = None + self.request_deadline = None + self.get_account_by_name = Mock(side_effect=account_results) + clients.append(self) + + def __enter__(self) -> Any: + return self + + def __exit__(self, *args: Any) -> bool: + return False + + monkeypatch.setattr(device_login.api, "ConnectCloudServer", CloudServer) + monkeypatch.setattr(device_login, "_DeviceLoginCloudClient", CloudClient) + monkeypatch.setattr(connect_cloud, "store_credentials_in_keyring", lambda *args: False) + original_set = ServerStore.set + failures = iter(save_failures) + + def maybe_fail_save(store: ServerStore, *args: Any, **kwargs: Any) -> Any: + failure = next(failures, None) + if failure is not None: + raise failure + return original_set(store, *args, **kwargs) + + monkeypatch.setattr(ServerStore, "set", maybe_fail_save) + finish_deadline = login_env.clock.monotonic() + 120 + with pytest.raises(expected_error, match=expected_message) as raised: + device_login.finish_login("cloud", "cloud-login") + + assert "cloud-access-secret" not in str(raised.value) + if isinstance(lookup_results[0], RSConnectException): + assert raised.value.status == lookup_results[0].status + assert raised.value.cause is lookup_results[0].cause + checkpoint = device_login._read_state("cloud", "cloud-login") + assert checkpoint["tokens"]["access_token"] == "cloud-access-secret" + assert len(login_env.http.instances) == 2 + + login_env.clock.advance(601) + resumed = device_login.start_cloud_login("team", "cloud-login") + assert resumed["user_code"] == "ABCD-EFGH" + assert device_login._read_state("cloud", "cloud-login")["tokens"]["access_token"] == "cloud-access-secret" + + completed = device_login.finish_login("cloud", "cloud-login") + saved = device_login._store().get_by_name("cloud-login") + assert completed == { + "status": "done", + "name": "cloud-login", + "server": result["server"], + "account": "team", + } + assert saved["connect_cloud_account_id"] == "team-id" + assert saved["connect_cloud_access_token"] == "cloud-access-secret" + assert device_login._store().get_default() is None + assert clients[0].request_timeout <= 120 + assert clients[0].request_deadline == finish_deadline + assert clients[0]._suppress_oauth_response_logging is True + assert not _state_path("cloud", "cloud-login").exists() + + +def test_cloud_refresh_invalid_grant_removes_the_consumed_device_checkpoint( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + _queue_device(login_env) + device_login.start_cloud_login("team", "cloud-login") + login_env.clock.advance(5) + login_env.http.responses.append( + {"access_token": "cloud-access", "refresh_token": "revoked-refresh", "expires_in": 3600} + ) + + class CloudServer: + def __init__(self, **kwargs: Any) -> None: + self.access_token = kwargs["access_token"] + self.refresh_token = kwargs["refresh_token"] + + class CloudClient: + request_timeout = None + request_deadline = None + + def __init__(self, server: Any) -> None: + self.server = server + + def __enter__(self) -> Any: + return self + + def __exit__(self, *args: Any) -> bool: + return False + + def get_account_by_name(self, account_name: str) -> dict[str, str]: + try: + raise oauth.InvalidGrantError("refresh token revoked") + except oauth.InvalidGrantError as error: + raise RSConnectException("Cloud session expired") from error + + monkeypatch.setattr(device_login.api, "ConnectCloudServer", CloudServer) + monkeypatch.setattr(device_login, "_DeviceLoginCloudClient", CloudClient) + + with pytest.raises(oauth.InvalidGrantError, match="OAuth grant is invalid"): + device_login.finish_login("cloud", "cloud-login") + + assert not _state_path("cloud", "cloud-login").exists() + with pytest.raises(RSConnectException, match="No pending"): + device_login.finish_login("cloud", "cloud-login") + + +def test_cloud_finish_refresh_uses_client_id_saved_at_start(login_env: Any, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + monkeypatch.setenv(connect_cloud.OAUTH_CLIENT_ID_ENV_VAR, "started-client") + _queue_device(login_env) + device_login.start_cloud_login("team", "cloud-login") + state = device_login._read_state("cloud", "cloud-login") + state["tokens"] = { + "access_token": "cloud-access", + "refresh_token": "cloud-refresh", + "expires_at": login_env.clock.time() + 3600, + } + monkeypatch.setenv(connect_cloud.OAUTH_CLIENT_ID_ENV_VAR, "current-client") + monkeypatch.setattr(device_login.api.time, "monotonic", lambda: 10.0) + refresh = Mock(return_value={"access_token": "refreshed-access"}) + monkeypatch.setattr(connect_cloud, "refresh", refresh) + + cloud_server, client, _ = device_login._cloud_login_client(state, 20.0) + client._refresh_user_token() + + assert cloud_server.oauth_client_id == "started-client" + refresh.assert_called_once_with( + "cloud-refresh", + "production", + request_timeout=10.0, + request_deadline=20.0, + client_id_override="started-client", + suppress_response_logging=True, + ) + + +def test_registration_error_is_private_and_can_be_retried(login_env: Any): + login_env.http.responses.extend( + [ + _response(200, METADATA), + _response(503, {"error": "server_error", "error_description": "device-code-secret"}), + ] + ) + + with pytest.raises(RSConnectException, match="client registration failed") as failed: + device_login.start_connect_login(SERVER, "work") + + assert "device-code-secret" not in str(failed.value) + assert not _state_path("connect", "work").exists() + assert login_env.http.instances[-1]._suppress_oauth_response_logging is True + login_env.http.responses.extend( + [ + _response(200, METADATA), + _response(201, {"client_id": "registered-client"}), + _response(200, DEVICE_RESPONSE), + ] + ) + assert device_login.start_connect_login(SERVER, "work")["status"] == "pending" + + +def test_missing_cloud_account_removes_checkpoint_for_corrected_start(login_env: Any, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + _queue_device(login_env) + device_login.start_cloud_login("typo", "cloud-login") + login_env.clock.advance(5) + login_env.http.responses.append( + {"access_token": "cloud-access", "refresh_token": "cloud-refresh", "expires_in": 3600} + ) + + monkeypatch.setattr( + device_login.api.ConnectCloudClient, + "get_accounts", + lambda self: [{"id": "team-id", "name": "correct-team"}], + ) + + with pytest.raises(ConnectCloudAccountNotFoundError): + device_login.finish_login("cloud", "cloud-login") + + assert not _state_path("cloud", "cloud-login").exists() + _queue_device(login_env) + corrected = device_login.start_cloud_login("correct-team", "cloud-login") + assert corrected["status"] == "pending" + assert device_login._read_state("cloud", "cloud-login")["account"] == "correct-team" + + +def test_cloud_account_deadline_returns_pending_and_checkpoints_rotated_tokens( + login_env: Any, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv(connect_cloud.ENVIRONMENT_ENV_VAR, "production") + _queue_device(login_env) + device_login.start_cloud_login("team", "cloud-login") + login_env.clock.advance(5) + login_env.http.responses.append( + {"access_token": "initial-access", "refresh_token": "initial-refresh", "expires_in": 3600} + ) + lookups = 0 + clients: list[Any] = [] + + class CloudServer: + def __init__(self, **kwargs: Any) -> None: + self.access_token = kwargs["access_token"] + self.refresh_token = kwargs["refresh_token"] + + class CloudClient: + def __init__(self, server: Any) -> None: + self.server = server + self.request_timeout = None + self.request_deadline = None + clients.append(self) + + def __enter__(self) -> Any: + return self + + def __exit__(self, *args: Any) -> bool: + return False + + def get_account_by_name(self, account_name: str) -> dict[str, str]: + nonlocal lookups + lookups += 1 + if lookups == 1: + self.server.access_token = "rotated-access" + self.server.refresh_token = "rotated-refresh" + login_env.clock.advance(5) + return {"id": "team-id"} + + monkeypatch.setattr(device_login.api, "ConnectCloudServer", CloudServer) + monkeypatch.setattr(device_login, "_DeviceLoginCloudClient", CloudClient) + monkeypatch.setattr(connect_cloud, "store_credentials_in_keyring", lambda *args: False) + + result = device_login.finish_login("cloud", "cloud-login", timeout=5) + checkpoint = device_login._read_state("cloud", "cloud-login") + + assert result == {"status": "pending", "name": "cloud-login", "server": connect_cloud.urls().api} + assert checkpoint["tokens"]["access_token"] == "rotated-access" + assert checkpoint["tokens"]["refresh_token"] == "rotated-refresh" + assert clients[0].request_deadline == 10.0 + + completed = device_login.finish_login("cloud", "cloud-login", timeout=5) + saved = device_login._store().get_by_name("cloud-login") + assert completed["status"] == "done" + assert saved["connect_cloud_access_token"] == "rotated-access" + assert saved["connect_cloud_refresh_token"] == "rotated-refresh" diff --git a/tests/test_http_support.py b/tests/test_http_support.py index f51822c5..4682c7b3 100644 --- a/tests/test_http_support.py +++ b/tests/test_http_support.py @@ -1,3 +1,8 @@ +import threading +import time +from contextlib import ExitStack, contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer as _TestHTTPServer +from typing import Any, Generator, cast from unittest import TestCase from rsconnect.http_support import ( @@ -5,11 +10,60 @@ _user_agent, _create_ssl_connection, append_to_path, - HTTPServer, CookieJar, + HTTPResponse, + HTTPServer, ) +class _DeadlineHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/slow-headers": + for part in ( + b"HTTP/1.0 200 OK\r\n", + b"Content-Length: 0\r\n", + b"Content-Type: application/json\r\n", + b"\r\n", + ): + if not self._send(part): + return + time.sleep(0.14) + elif self.path == "/slow-body": + if not self._send(b"HTTP/1.0 200 OK\r\nContent-Length: 8\r\nContent-Type: application/json\r\n\r\n"): + return + for byte in b"12345678": + if not self._send(bytes((byte,))): + return + time.sleep(0.08) + else: + self._send(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Type: application/json\r\n\r\n{}") + + def _send(self, data: bytes) -> bool: + try: + self.connection.sendall(data) + except OSError: + return False + return True + + def log_message(self, format: str, *args: object) -> None: + pass + + +@contextmanager +def _deadline_test_server() -> Generator[str, None, None]: + server = _TestHTTPServer(("127.0.0.1", 0), _DeadlineHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + if thread.is_alive(): + raise AssertionError("HTTP test server thread did not stop.") + + class TestHTTPSupport(TestCase): def test_connection_factory_map(self): self.assertEqual(len(_connection_factory), 2) @@ -31,6 +85,196 @@ def test_HTTPServer_instantiation_error(self): with self.assertRaises(ValueError): HTTPServer("ftp://example.com") + def test_request_timeout_override_does_not_change_the_default(self): + from unittest.mock import patch + + with patch("rsconnect.http_support.get_request_timeout", return_value=37): + with HTTPServer("http://example.com", request_timeout=0.25) as bounded: + self.assertEqual(bounded._conn.timeout, 0.25) + with HTTPServer("http://example.com") as ordinary: + self.assertEqual(ordinary._conn.timeout, 37) + + def test_expired_request_deadline_prevents_network_io(self): + import socket + from unittest.mock import patch + + with patch("rsconnect.http_support.time.monotonic", return_value=100): + with HTTPServer("http://example.com", request_deadline=99) as server: + with patch.object(server._conn, "request") as send: + response = server.get("/settings") + self.assertIsInstance(response.exception, socket.timeout) + send.assert_not_called() + + def test_deadline_updates_an_existing_socket_for_each_request(self): + from unittest.mock import Mock, patch + + clock = [100] + with patch("rsconnect.http_support.time.monotonic", side_effect=lambda: clock[0]): + with HTTPServer("http://example.com", request_timeout=20, request_deadline=110) as server: + transport = server._conn + transport.sock = Mock() + reply = Mock() + reply.status = 200 + reply.read.return_value = b"{}" + reply.getheaders.return_value = [] + reply.getheader.return_value = "application/json" + with patch.object(transport, "request"): + with patch.object(transport, "getresponse", return_value=reply): + server.get("/first") + self.assertEqual(transport.timeout, 10) + transport.sock.settimeout.assert_called_with(10) + clock[0] = 107 + server.get("/next") + self.assertEqual(transport.timeout, 3) + transport.sock.settimeout.assert_called_with(3) + transport.sock = None + + def test_deadline_uses_remaining_time_when_request_timeout_is_disabled(self): + from unittest.mock import Mock, patch + + with patch("rsconnect.http_support.time.monotonic", return_value=100): + with patch("rsconnect.http_support.get_request_timeout", return_value=0): + with HTTPServer("http://example.com", request_deadline=110) as server: + transport = cast(Any, server._conn) + reply = Mock() + reply.status = 200 + reply.reason = "OK" + reply.read.return_value = b"{}" + reply.getheaders.return_value = [] + reply.getheader.return_value = "application/json" + with patch.object(transport, "request"): + with patch.object(transport, "getresponse", return_value=reply): + server.get("/settings") + self.assertEqual(transport.timeout, 10) + + def test_no_deadline_keeps_http_connection_call_shapes(self): + from unittest.mock import Mock, patch + + with HTTPServer("http://example.com") as server: + transport = cast(Any, server._conn) + reply = Mock() + reply.status = 200 + reply.reason = "OK" + reply.read.return_value = b"{}" + reply.getheaders.return_value = [] + reply.getheader.return_value = "application/json" + with patch("rsconnect.http_support.threading.Timer", side_effect=AssertionError): + with patch.object(transport, "request") as send: + with patch.object(transport, "getresponse", return_value=reply) as receive: + response = cast(HTTPResponse, server.get("/settings")) + + send.assert_called_once_with("GET", "/settings", None, {"User-Agent": _user_agent}) + receive.assert_called_once_with() + reply.read.assert_called_once_with() + self.assertEqual(response.status, 200) + + def test_completed_token_response_survives_deadline_and_prevents_another_request(self): + import socket + from unittest.mock import Mock, patch + + for timer_fired in (False, True): + clock = [100] + with self.subTest(timer_fired=timer_fired), ExitStack() as stack: + stack.enter_context(patch("rsconnect.http_support.time.monotonic", side_effect=lambda: clock[0])) + server = stack.enter_context(HTTPServer("http://example.com", request_deadline=110)) + timer = stack.enter_context(patch("rsconnect.http_support.threading.Timer")) + interrupt = stack.enter_context(patch("rsconnect.http_support._interrupt_socket")) + transport = cast(Any, server._conn) + transport.sock = Mock() + reply = Mock() + reply.status = 200 + reply.reason = "OK" + reply.getheaders.return_value = [] + reply.getheader.return_value = "application/json" + + def read_completed_body(): + clock[0] = 111 + if timer_fired: + timer.call_args.args[1]() + return b'{"access_token":"completed-token","refresh_token":"saved-refresh"}' + + reply.read.side_effect = read_completed_body + send = stack.enter_context(patch.object(transport, "request")) + stack.enter_context(patch.object(transport, "getresponse", return_value=reply)) + response = cast(HTTPResponse, server.get("/token")) + next_response = cast(HTTPResponse, server.get("/accounts")) + + self.assertIsNone(response.exception) + self.assertEqual( + response.json_data, + {"access_token": "completed-token", "refresh_token": "saved-refresh"}, + ) + self.assertIsInstance(next_response.exception, socket.timeout) + self.assertEqual(send.call_count, 1) + self.assertEqual(interrupt.call_count, int(timer_fired)) + timer.return_value.cancel.assert_called_once_with() + timer.return_value.join.assert_called_once_with() + transport.sock = None + + def test_deadline_interrupts_slow_response_headers(self): + import socket + + with _deadline_test_server() as url: + deadline = time.monotonic() + 0.2 + with HTTPServer(url, request_timeout=1, request_deadline=deadline) as server: + started = time.monotonic() + response = cast(HTTPResponse, server.get("/slow-headers")) + elapsed = time.monotonic() - started + + self.assertIsInstance(response.exception, socket.timeout) + self.assertLess(elapsed, 0.35) + + def test_deadline_interrupts_http10_slow_body_and_joins_timer(self): + import socket + from unittest.mock import patch + + timers: list[threading.Timer] = [] + + class TrackingTimer(threading.Timer): + def start(self) -> None: + timers.append(self) + super().start() + + with _deadline_test_server() as url: + with patch("rsconnect.http_support.threading.Timer", new=TrackingTimer): + deadline = time.monotonic() + 0.2 + with HTTPServer(url, request_timeout=1, request_deadline=deadline) as server: + started = time.monotonic() + response = cast(HTTPResponse, server.get("/slow-body")) + elapsed = time.monotonic() - started + + self.assertIsInstance(response.exception, socket.timeout) + self.assertLess(elapsed, 0.45) + self.assertEqual(len(timers), 1) + self.assertFalse(timers[0].is_alive()) + + def test_deadline_timer_is_opt_in_and_cancelled_after_success(self): + from unittest.mock import patch + + timers: list[threading.Timer] = [] + + class TrackingTimer(threading.Timer): + def start(self) -> None: + timers.append(self) + super().start() + + with _deadline_test_server() as url: + with patch("rsconnect.http_support._interrupt_socket") as interrupt: + with patch("rsconnect.http_support.threading.Timer", new=TrackingTimer): + with HTTPServer(url) as ordinary: + response = cast(HTTPResponse, ordinary.get("/quick")) + self.assertEqual(response.status, 200) + self.assertEqual(timers, []) + + with HTTPServer(url, request_deadline=time.monotonic() + 2) as bounded: + response = cast(HTTPResponse, bounded.get("/quick")) + self.assertEqual(response.status, 200) + + interrupt.assert_not_called() + + self.assertEqual(len(timers), 1) + self.assertFalse(timers[0].is_alive()) + def test_header_stuff(self): server = HTTPServer("http://example.com") self.assertIsNone(server.get_authorization()) @@ -122,6 +366,19 @@ def test_cookie_values_do_not_reach_the_debug_log(self): self.assertNotIn("s3ssionv4lue", log_text) self.assertIn("session", log_text) + def test_cookie_names_and_values_can_be_omitted_when_opted_in(self): + from unittest.mock import patch + + jar = CookieJar() + cookie_name = "oauth-refresh-cookie-name" + cookie_value = "oauth-refresh-cookie-value" + with patch("rsconnect.http_support.logger.debug") as debug: + jar.store_cookies(FakeSetCookieResponse([f"{cookie_name}={cookie_value}"]), suppress_logs=True) + header = jar.get_cookie_header_value(suppress_logs=True) + + debug.assert_not_called() + self.assertEqual(header, f"{cookie_name}={cookie_value}") + class TestDebugLogRedaction(TestCase): """Credential material must not reach the debug (-vv) log.""" @@ -235,6 +492,184 @@ def test_cookie_values_with_spaces_leave_no_first_token(self): self.assertEqual(_redacted_header_for_log("Cookie", "session=abc; other=def"), "") + def test_oauth_redirect_destination_is_suppressed_when_opted_in(self): + from unittest.mock import Mock, patch + + location = "http://example.com/opaque/path-refresh-token?resume=opaque-query-token" + request_target = "/opaque/path-refresh-token?resume=opaque-query-token" + + def make_response(status, body, headers, reason): + response = Mock() + response.status = status + response.reason = reason + response.read.return_value = body + response.getheaders.return_value = headers + header_values = {key.lower(): value for key, value in headers} + response.getheader.side_effect = lambda key, default=None: header_values.get(key.lower(), default) + return response + + redirect = make_response(302, b"", [("Location", location)], "reason-refresh-token") + final = make_response(200, b"{}", [("Content-Type", "application/json")], "final-reason-refresh-token") + + with HTTPServer("http://example.com") as server: + server._suppress_oauth_response_logging = True + transport = cast(Any, server._conn) + with self.assertLogs("rsconnect", level="DEBUG") as captured: + with patch.object(transport, "request") as send: + with patch.object(transport, "getresponse", side_effect=[redirect, final]): + response = cast(HTTPResponse, server.post("/oauth/start", body=b"payload")) + + log_text = "\n".join(captured.output) + for secret in ("path-refresh-token", "opaque-query-token", "reason-refresh-token"): + self.assertNotIn(secret, log_text) + self.assertIn("Following HTTP redirect", log_text) + self.assertEqual(send.call_args_list[0].args[:3], ("POST", "/oauth/start", b"payload")) + self.assertEqual(send.call_args_list[1].args[:3], ("GET", request_target, b"payload")) + self.assertEqual(response.full_uri, request_target) + self.assertEqual(response.reason, "final-reason-refresh-token") + self.assertEqual(response.response_body, "{}") + + def test_oauth_response_body_is_omitted_when_opted_in(self): + from unittest.mock import Mock, patch + + secret = "echoed-refresh-token" + cookie_name = "oauth-cookie-name" + cookie_value = "oauth-cookie-value" + endpoint = "/oauth/token?refresh_token=endpoint-secret" + body = f'{{"error":"{secret}","error_description":"{secret}"}}' + content_type = f"application/json; debug={secret}" + reply = Mock() + reply.status = 503 + reply.reason = "reason-secret" + reply.read.return_value = body.encode() + reply.getheaders.return_value = [ + ("Content-Type", content_type), + ("X-Debug-Context", secret), + ("Set-Cookie", f"{cookie_name}={cookie_value}"), + ] + header_values = {key.lower(): value for key, value in reply.getheaders.return_value} + reply.getheader.side_effect = lambda key, default=None: header_values.get(key.lower(), default) + + with HTTPServer("http://example.com") as server: + server._suppress_oauth_response_logging = True + transport = cast(Any, server._conn) + with self.assertLogs("rsconnect", level="DEBUG") as captured: + with patch.object(transport, "request"): + with patch.object(transport, "getresponse", return_value=reply): + response = cast(HTTPResponse, server.get(endpoint)) + + log_text = "\n".join(captured.output) + self.assertIn("Response: 503", log_text) + self.assertIn("", log_text) + self.assertIn("", log_text) + for private_value in (secret, cookie_name, cookie_value, "endpoint-secret", "X-Debug-Context", "reason-secret"): + self.assertNotIn(private_value, log_text) + self.assertEqual(response.reason, "reason-secret") + self.assertEqual(response.content_type, content_type) + self.assertEqual(response._response.getheader("Content-Type"), content_type) + self.assertEqual(response._response.getheader("X-Debug-Context"), secret) + self.assertEqual(response._response.getheader("Set-Cookie"), f"{cookie_name}={cookie_value}") + self.assertEqual(response.response_body, body) + self.assertEqual(response.json_data, {"error": secret, "error_description": secret}) + + def test_default_oauth_response_logging_keeps_baseline_diagnostics(self): + from unittest.mock import Mock, patch + + body = '{"message":"ordinary response"}' + reply = Mock() + reply.status = 200 + reply.reason = "ordinary reason" + reply.read.return_value = body.encode() + reply.getheaders.return_value = [("Content-Type", "application/json"), ("X-Debug-Context", "ordinary context")] + header_values = {key.lower(): value for key, value in reply.getheaders.return_value} + reply.getheader.side_effect = lambda key, default=None: header_values.get(key.lower(), default) + + with HTTPServer("http://example.com") as server: + transport = cast(Any, server._conn) + with self.assertLogs("rsconnect", level="DEBUG") as captured: + with patch.object(transport, "request"): + with patch.object(transport, "getresponse", return_value=reply): + response = cast(HTTPResponse, server.get("/token")) + + log_text = "\n".join(captured.output) + self.assertIn("Response: 200 ordinary reason", log_text) + self.assertIn("X-Debug-Context: ordinary context", log_text) + self.assertIn("ordinary response", log_text) + self.assertNotIn(" None: + os.makedirs(os.path.dirname(self.store._primary_path), exist_ok=True) + with open(self.store._primary_path, "wb") as metadata_file: + metadata_file.write(data) + + def test_strict_read_loads_regular_metadata_and_missing_store(self): + self._write_primary(json.dumps(self.contents).encode("utf-8")) + + loaded = AppStore(self.app_file, strict_read=True) + + self.assertEqual(loaded.get("https://connect.example.test"), self.record) + missing = AppStore(join(self.tempdir, "missing.py"), strict_read=True) + self.assertIsNone(missing.get("https://connect.example.test")) + + def test_strict_read_uses_secondary_when_primary_is_missing(self): + config_dir = join(self.tempdir, "config") + with patch("rsconnect.metadata.config_dirname", return_value=config_dir): + unloaded = AppStore(self.app_file, autoload=False, strict_read=True) + secondary_path = unloaded._secondary_path + assert secondary_path is not None + os.makedirs(os.path.dirname(secondary_path), exist_ok=True) + with open(secondary_path, "w", encoding="utf-8") as metadata_file: + json.dump(self.contents, metadata_file) + + loaded = AppStore(self.app_file, strict_read=True) + + self.assertEqual(loaded.get("https://connect.example.test"), self.record) + self.assertEqual(loaded.get_path(), secondary_path) + + def test_strict_read_rejects_symlinked_primary_and_secondary(self): + target = join(self.tempdir, "target.json") + with open(target, "w", encoding="utf-8") as metadata_file: + json.dump(self.contents, metadata_file) + os.makedirs(os.path.dirname(self.store._primary_path), exist_ok=True) + try: + os.symlink(target, self.store._primary_path) + except (NotImplementedError, OSError): + self.skipTest("symlinks are unavailable") + + with self.assertRaisesRegex(OSError, "symlink"): + AppStore(self.app_file, strict_read=True) + self.assertEqual(AppStore(self.app_file).get("https://connect.example.test"), self.record) + + os.unlink(self.store._primary_path) + config_dir = join(self.tempdir, "config") + with patch("rsconnect.metadata.config_dirname", return_value=config_dir): + unloaded = AppStore(self.app_file, autoload=False, strict_read=True) + secondary_path = unloaded._secondary_path + assert secondary_path is not None + os.makedirs(os.path.dirname(secondary_path), exist_ok=True) + os.symlink(target, secondary_path) + with self.assertRaisesRegex(OSError, "symlink"): + AppStore(self.app_file, strict_read=True) + + def test_strict_read_rejects_oversized_metadata_before_parsing(self): + oversized = json.dumps({"https://connect.example.test": {"title": "x" * (2 * 1024 * 1024)}}).encode("utf-8") + self.assertGreater(len(oversized), 1024 * 1024) + self._write_primary(oversized) + + with self.assertRaisesRegex(OSError, "limit"): + AppStore(self.app_file, strict_read=True) + + def test_strict_read_rejects_fifo_without_blocking(self): + if not hasattr(os, "mkfifo"): + self.skipTest("FIFOs are unavailable") + os.makedirs(os.path.dirname(self.store._primary_path), exist_ok=True) + try: + os.mkfifo(self.store._primary_path) + except OSError: + self.skipTest("FIFOs are unavailable") + + environment = os.environ.copy() + environment.update(HOME=self.tempdir, XDG_CONFIG_HOME=self.tempdir, APPDATA=self.tempdir) + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; from rsconnect.metadata import AppStore; AppStore(sys.argv[1], strict_read=True)", + self.app_file, + ], + cwd=os.getcwd(), + env=environment, + capture_output=True, + text=True, + timeout=5, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("non-regular", result.stderr) + + class TestHelpers(TestCase): def test_normalize_server_url(self): self.assertEqual("localhost_3939", _normalize_server_url("https://localhost:3939")) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 2f2c29cb..66daaf54 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -74,6 +74,17 @@ def test_success(self, mock_http_server: MagicMock): result = discover_oauth_metadata(FAKE_URL) assert result == FAKE_METADATA + def test_client_specific_request_timeout_and_deadline(self, mock_http_server: MagicMock): + mock_http_server.get.return_value = _make_response(200, FAKE_METADATA) + assert discover_oauth_metadata(FAKE_URL, request_timeout=0.25, request_deadline=12.5) == FAKE_METADATA + assert mock_http_server.request_timeout == 0.25 + assert mock_http_server.request_deadline == 12.5 + + def test_response_logging_can_be_suppressed(self, mock_http_server: MagicMock): + mock_http_server.get.return_value = _make_response(200, FAKE_METADATA) + assert discover_oauth_metadata(FAKE_URL, suppress_response_logging=True) == FAKE_METADATA + assert mock_http_server._suppress_oauth_response_logging is True + def test_server_not_supporting_oauth(self, mock_http_server: MagicMock): mock_http_server.get.return_value = _make_response(404, None) with pytest.raises(RSConnectException, match="does not support OAuth"): @@ -91,12 +102,28 @@ def test_success(self, mock_http_server: MagicMock): result = register_client(FAKE_METADATA, FAKE_URL) assert result == "test-client-123" + def test_client_specific_request_timeout_and_deadline(self, mock_http_server: MagicMock): + mock_http_server.post.return_value = _make_response(200, {"client_id": "bounded-client"}) + assert register_client(FAKE_METADATA, FAKE_URL, request_timeout=0.25, request_deadline=12.5) == "bounded-client" + assert mock_http_server.request_timeout == 0.25 + assert mock_http_server.request_deadline == 12.5 + + def test_response_logging_can_be_suppressed(self, mock_http_server: MagicMock): + mock_http_server.post.return_value = _make_response(200, {"client_id": "test-client-123"}) + assert register_client(FAKE_METADATA, FAKE_URL, suppress_response_logging=True) == "test-client-123" + assert mock_http_server._suppress_oauth_response_logging is True + def test_failure(self, mock_http_server: MagicMock): mock_http_server.post.return_value = _make_response( 400, {"error": "invalid_request", "error_description": "bad request"} ) - with pytest.raises(RSConnectException, match="OAuth error"): + with pytest.raises(RSConnectException, match="OAuth error") as raised: register_client(FAKE_METADATA, FAKE_URL) + assert "bad request" in str(raised.value) + + def test_client_id_response_keeps_legacy_stringification(self, mock_http_server: MagicMock): + mock_http_server.post.return_value = _make_response(200, {"client_id": "bad\nclient"}) + assert register_client(FAKE_METADATA, FAKE_URL) == "bad\nclient" def test_missing_registration_endpoint(self): metadata = {k: v for k, v in FAKE_METADATA.items() if k != "registration_endpoint"} @@ -263,6 +290,28 @@ def test_poll_success(self, _, mock_http_server: MagicMock): result = _poll_for_device_token(FAKE_METADATA, "client-1", "device-code-1", 5, 600) assert result["access_token"] == "at-final" + @patch("rsconnect.oauth.time.sleep") + @patch("rsconnect.oauth.webbrowser.open", return_value=False) + def test_legacy_device_flow_keeps_path_only_when_endpoint_has_query( + self, _open: MagicMock, _, mock_http_server: MagicMock + ): + metadata = { + **FAKE_METADATA, + "device_authorization_endpoint": FAKE_METADATA["device_authorization_endpoint"] + "?tenant=acme", + "token_endpoint": FAKE_METADATA["token_endpoint"] + "?tenant=acme", + } + mock_http_server.request.side_effect = [ + _make_response(200, {"device_code": "dc", "user_code": "UC", "verification_uri": "https://verify"}), + _make_response(200, {"access_token": "at"}), + ] + + login_with_device_code(FAKE_URL, "client-1", metadata) + + assert [call.args[1] for call in mock_http_server.request.call_args_list] == [ + "/oauth/v1/device", + "/oauth/v1/token", + ] + @patch("rsconnect.oauth.time.sleep") def test_poll_expired(self, _, mock_http_server: MagicMock): mock_http_server.request.return_value = _make_response(400, {"error": "expired_token"}) @@ -281,8 +330,60 @@ def test_poll_malformed_response(self, _, mock_http_server: MagicMock): with pytest.raises(RSConnectException, match="unexpected response"): _poll_for_device_token(FAKE_METADATA, "client-1", "device-code-1", 5, 600) + @pytest.mark.parametrize( + ("response", "message"), + [ + (_make_response(502), "Device code token request failed: HTTP 502."), + (object(), "Device code token request returned an unexpected response."), + ], + ) + def test_poll_preserves_raw_response_errors( + self, monkeypatch: pytest.MonkeyPatch, mock_http_server: MagicMock, response: Any, message: str + ): + monkeypatch.setattr("rsconnect.oauth.time.sleep", lambda _interval: None) + mock_http_server.request.return_value = response + + with pytest.raises(RSConnectException) as raised: + _poll_for_device_token(FAKE_METADATA, "client-1", "device-code-1", 5, 600) + + assert str(raised.value) == message + class TestRefreshAccessToken: + def test_client_specific_request_timeout_and_deadline(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(200, {"access_token": "new-at"}) + refresh_access_token( + FAKE_METADATA, + "client-1", + "old-rt", + request_timeout=0.25, + request_deadline=12.5, + ) + assert mock_http_server.request_timeout == 0.25 + assert mock_http_server.request_deadline == 12.5 + + def test_response_logging_can_be_suppressed(self, mock_http_server: MagicMock): + mock_http_server.request.return_value = _make_response(200, {"access_token": "new-at"}) + refresh_access_token( + FAKE_METADATA, + "client-1", + "old-rt", + request_timeout=0.25, + request_deadline=12.5, + suppress_response_logging=True, + ) + assert mock_http_server.request_timeout == 0.25 + assert mock_http_server.request_deadline == 12.5 + assert mock_http_server._suppress_oauth_response_logging is True + + def test_refresh_keeps_legacy_path_only_for_token_query(self, mock_http_server: MagicMock): + metadata = {**FAKE_METADATA, "token_endpoint": FAKE_METADATA["token_endpoint"] + "?tenant=acme"} + mock_http_server.request.return_value = _make_response(200, {"access_token": "new-at"}) + + refresh_access_token(metadata, "client-1", "old-rt") + + assert mock_http_server.request.call_args.args[1] == "/oauth/v1/token" + def test_success(self, mock_http_server: MagicMock): mock_http_server.request.return_value = _make_response( 200, {"access_token": "new-at", "refresh_token": "new-rt", "expires_in": 7200} @@ -721,6 +822,13 @@ def fake_super_request( class TestLoginCommand: + @pytest.fixture(autouse=True) + def isolated_server_store(self, tmp_path: Any, monkeypatch: Any): + from rsconnect import main + from rsconnect.metadata import ServerStore + + monkeypatch.setattr(main, "server_store", ServerStore(str(tmp_path))) + @patch("rsconnect.oauth.keyring_store_token", return_value=True) @patch("rsconnect.oauth.login_with_browser") @patch("rsconnect.oauth.register_client", return_value="new-client-id") diff --git a/tests/test_preflight.py b/tests/test_preflight.py new file mode 100644 index 00000000..e1a1d482 --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,810 @@ +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, cast + +import pytest + +from rsconnect.api import ConnectCloudServer, RSConnectClient, RSConnectExecutor, RSConnectServer, SPCSConnectServer +from rsconnect.exception import RSConnectException +from rsconnect.environment import fake_module_file_from_directory +from rsconnect.metadata import AppStore, sha1 +from rsconnect.preflight import load_preflight_app_store, run_preflight + + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Agent login and preflight require POSIX.") + +SERVER_URL = "https://connect.example.test" +DEFAULT_SETTINGS = { + "installations": [ + {"version": "3.12.8", "publishable": True}, + {"version": "3.11.9", "publishable": True}, + ] +} + + +class Client(RSConnectClient): + def __init__(self, settings: Any, content: dict[str, dict[str, Any]] | None = None): + self.settings = settings + self.content = content or {} + self.content_requests: list[str] = [] + + def python_settings(self) -> Any: + return self.settings + + def get_content_by_id(self, id: str) -> Any: + self.content_requests.append(id) + return self.content.get(id, {}) + + +class Store: + def __init__(self, record: dict[str, Any] | None = None): + self.record = record + self.requests: list[str] = [] + + def get(self, server_url: str): + self.requests.append(server_url) + return self.record + + +def make_executor( + project: Path, + *, + settings: Any = None, + content: dict[str, dict[str, Any]] | None = None, + record: dict[str, Any] | None = None, + app_id: str | None = None, + new: bool = False, + store: Any = None, + server: Any = None, +) -> tuple[RSConnectExecutor, Client, Any]: + client = Client(DEFAULT_SETTINGS if settings is None else settings, content) + app_store = Store() if store is None else store + executor = cast(Any, RSConnectExecutor.__new__(RSConnectExecutor)) + executor.remote_server = server or RSConnectServer(SERVER_URL, "api-key") + executor.client = client + executor.app_store = app_store + executor.path = str(project) + executor.app_id = app_id + executor.new = new + if record is not None: + module_file = Path(fake_module_file_from_directory(str(project))) + metadata_path = module_file.parent / "rsconnect-python" / f"{module_file.stem}.json" + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(json.dumps({executor.record_server_key(): record}), encoding="utf-8") + return cast(RSConnectExecutor, executor), client, app_store + + +def save_deployment_record(app_file: Path, app_id: str) -> None: + AppStore(str(app_file)).set( + SERVER_URL, + str(app_file), + "https://connect.example.test/content", + app_id, + app_id, + "test", + "python-shiny", + ) + + +@pytest.fixture(autouse=True) +def preflight_environment(monkeypatch): + monkeypatch.setattr("rsconnect.preflight.platform.python_version", lambda: "3.12.2") + monkeypatch.setattr("rsconnect.preflight.shutil.which", lambda _: "/usr/bin/quarto") + + +def test_metadata_precedence_and_explicit_constraint_are_preserved(tmp_path): + (tmp_path / ".python-version").write_text("3.10\n") + (tmp_path / "pyproject.toml").write_text('[project]\nrequires-python = ">=3.12"\n') + (tmp_path / "setup.cfg").write_text("[options]\npython_requires = >=3.13\n") + executor, _, _ = make_executor( + tmp_path, + new=True, + settings={"installations": [{"version": "3.10.8", "publishable": True}]}, + ) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["python_requires"] == "~=3.10.0" + assert result["status"] == "ok" + assert result["changed_files"] == [] + assert (tmp_path / ".python-version").read_text() == "3.10\n" + + +def test_new_constraint_uses_exact_patch_versions(tmp_path): + (tmp_path / ".python-version").write_text("==3.11.8\n") + executor, _, _ = make_executor( + tmp_path, + new=True, + settings={"installations": [{"version": "3.11.9", "publishable": True}]}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "incompatible" + assert result["publishable_python_versions"] == ["3.11.9"] + + +def test_existing_content_checks_all_installed_versions_for_redeploy(tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nrequires-python = "==3.11.9"\n') + executor, client, store = make_executor( + tmp_path, + settings={ + "installations": [ + {"version": "3.10.14", "publishable": True}, + {"version": "3.11.9", "publishable": False}, + ] + }, + content={"saved-guid": {"py_version": "3.10.14"}}, + record={"app_id": "saved-id", "app_guid": "saved-guid"}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert store.requests == [] + assert client.content_requests == ["saved-guid"] + assert result["status"] == "ok" + assert result["publishable_python_versions"] == ["3.10.14"] + assert result["existing_content"] == { + "exists": True, + "app_id": "saved-guid", + "installed_python_version": "3.10.14", + "server_python_versions": ["3.10.14", "3.11.9"], + "python_compatibility": "compatible", + } + + new_executor, _, _ = make_executor( + tmp_path, + new=True, + settings={ + "installations": [ + {"version": "3.10.14", "publishable": True}, + {"version": "3.11.9", "publishable": False}, + ] + }, + ) + (tmp_path / "pyproject.toml").write_text('[project]\nrequires-python = "==3.11.9"\n') + + new_result = run_preflight(new_executor, str(tmp_path)) + + assert new_result["status"] == "incompatible" + + +def test_explicit_app_id_overrides_local_record(tmp_path): + executor, client, store = make_executor( + tmp_path, + app_id="explicit-guid", + record={"app_id": "record-id"}, + content={"explicit-guid": {"py_version": "3.12.1"}}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert store.requests == [] + assert client.content_requests == ["explicit-guid"] + assert result["existing_content"]["app_id"] == "explicit-guid" + + +def test_incomplete_deployment_record_is_unknown_and_does_not_fix(tmp_path): + executor, _, _ = make_executor(tmp_path, record={"title": "unfinished deployment"}) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert any("no content ID" in warning for warning in result["warnings"]) + assert any("--app-id" in action and "--new" in action for action in result["actions"]) + assert not (tmp_path / ".python-version").exists() + + executor.new = True + result = run_preflight(executor, str(tmp_path)) + assert result["existing_content"]["exists"] is False + + executor.new = False + executor.app_id = "explicit-guid" + result = run_preflight(executor, str(tmp_path)) + assert result["existing_content"]["app_id"] == "explicit-guid" + + +@pytest.mark.parametrize("candidate", ["directory", "module", "manifest"]) +def test_directory_resolves_each_info_lookup_candidate(tmp_path, candidate): + project = tmp_path / "project" + project.mkdir() + module = Path(fake_module_file_from_directory(str(project))) + manifest = project / "manifest.json" + target = { + "directory": project, + "module": module, + "manifest": manifest, + }[candidate] + save_deployment_record(target, "saved-guid") + executor, client, _ = make_executor(project) + + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "ok" + assert result["existing_content"]["app_id"] == "saved-guid" + assert client.content_requests == ["saved-guid"] + assert result["changed_files"] == [] + assert not (project / ".python-version").exists() + + +def test_ambiguous_directory_records_are_unknown_and_do_not_fix(tmp_path): + project = tmp_path / "project" + project.mkdir() + save_deployment_record(project, "directory-guid") + save_deployment_record(project / "manifest.json", "manifest-guid") + executor, client, _ = make_executor(project) + + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] is None + assert client.content_requests == [] + assert result["changed_files"] == [] + assert any("Multiple local deployment records" in warning for warning in result["warnings"]) + assert not (project / ".python-version").exists() + + +@pytest.mark.parametrize("store_kind", ["file", "executor"]) +@pytest.mark.parametrize("source_name", ["report.ipynb", "report.qmd"]) +def test_directory_with_single_file_deployment_is_unknown_and_does_not_fix( + tmp_path, monkeypatch, store_kind, source_name +): + project = tmp_path / "project" + project.mkdir() + report = project / source_name + report.write_text("{}", encoding="utf-8") + store = None + if store_kind == "file": + save_deployment_record(report, "saved-guid") + else: + config_dir = tmp_path / "user-config" + monkeypatch.setattr("rsconnect.metadata.config_dirname", lambda: str(config_dir)) + file_store = AppStore(fake_module_file_from_directory(str(report))) + file_store.set( + SERVER_URL, + str(report), + "https://connect.example.test/content", + "saved-guid", + "saved-guid", + "test", + "python-shiny", + ) + store = Store() + executor, client, _ = make_executor(project, store=store) + + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] is None + assert client.content_requests == [] + assert result["changed_files"] == [] + assert any("exact file path" in action.lower() for action in result["actions"]) + assert not (project / ".python-version").exists() + + +def test_unreadable_appstore_is_unknown_and_does_not_fix(tmp_path): + project = tmp_path / "project" + project.mkdir() + store_path = tmp_path / "rsconnect-python" / "project.json" + store_path.parent.mkdir() + store_path.write_text("{invalid", encoding="utf-8") + executor, _, _ = make_executor(project) + + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["changed_files"] == [] + assert result["actions"] + assert not (project / ".python-version").exists() + + +def test_oversized_valid_appstore_is_unknown_and_never_fixed(tmp_path): + project = tmp_path / "project" + project.mkdir() + module_file = Path(fake_module_file_from_directory(str(project))) + metadata_path = module_file.parent / "rsconnect-python" / f"{module_file.stem}.json" + metadata_path.parent.mkdir() + oversized = json.dumps({SERVER_URL: {"app_id": "saved-guid", "title": "x" * (2 * 1024 * 1024)}}) + assert len(oversized.encode("utf-8")) > 1024 * 1024 + metadata_path.write_text(oversized, encoding="utf-8") + executor, _, _ = make_executor(project) + + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] is None + assert result["changed_files"] == [] + assert any("byte limit" in warning for warning in result["warnings"]) + assert result["actions"] + assert not (project / ".python-version").exists() + + +@pytest.mark.parametrize( + "contents", + [ + pytest.param("[" * 10000 + "0" + "]" * 10000, id="nested-json"), + pytest.param( + '{"app_id":' + "9" * 5000 + "}", + id="integer-digit-limit", + marks=pytest.mark.skipif( + not 0 < getattr(sys, "get_int_max_str_digits", lambda: 0)() < 5000, + reason="Interpreter does not enforce this integer digit limit.", + ), + ), + ], +) +def test_unusable_appstore_metadata_is_unknown_and_never_fixed(tmp_path, contents): + project = tmp_path / "project" + project.mkdir() + module_file = Path(fake_module_file_from_directory(str(project))) + metadata_path = module_file.parent / "rsconnect-python" / f"{module_file.stem}.json" + metadata_path.parent.mkdir() + metadata_path.write_text(contents, encoding="utf-8") + + store = load_preflight_app_store(str(project)) + assert store.get_all() == [] + executor, client, _ = make_executor(project, store=store) + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert result["actions"] + assert any("local deployment metadata" in warning.lower() for warning in result["warnings"]) + assert client.content_requests == [] + assert not (project / ".python-version").exists() + + +def test_recursion_error_reading_appstore_is_unknown_and_never_fixed(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + real_app_store = AppStore + + def recursive_app_store(path, *args, **kwargs): + if kwargs.get("autoload", True): + raise RecursionError("simulated nested metadata") + return real_app_store(path, *args, **kwargs) + + monkeypatch.setattr("rsconnect.preflight.AppStore", recursive_app_store) + store = load_preflight_app_store(str(project)) + assert store.get_all() == [] + executor, client, _ = make_executor(project, store=store) + + result = run_preflight(executor, str(project), fix=True) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert any("local deployment metadata" in warning.lower() for warning in result["warnings"]) + assert client.content_requests == [] + assert not (project / ".python-version").exists() + + +def test_load_preflight_app_store_uses_exact_file_history(tmp_path): + project = tmp_path / "project" + project.mkdir() + report = project / "report.ipynb" + report.write_text("{}", encoding="utf-8") + save_deployment_record(report, "exact-guid") + + store = load_preflight_app_store(str(report)) + + assert store.get(SERVER_URL)["app_guid"] == "exact-guid" + + +def test_load_preflight_app_store_falls_back_to_historical_synthetic_file_key(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + report = project / "report.ipynb" + report.write_text("{}", encoding="utf-8") + config_dir = tmp_path / "user-config" + monkeypatch.setattr("rsconnect.metadata.config_dirname", lambda: str(config_dir)) + synthetic_store = AppStore(fake_module_file_from_directory(str(report))) + synthetic_store.set( + SERVER_URL, + str(report), + "https://connect.example.test/content", + "saved-guid", + "saved-guid", + "test", + "python-shiny", + ) + + store = load_preflight_app_store(str(report)) + + assert store.get(SERVER_URL)["app_guid"] == "saved-guid" + + +def test_malformed_record_id_is_unknown_and_does_not_fix(tmp_path): + executor, client, _ = make_executor(tmp_path, record={"app_id": ["not-a-content-id"]}) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] is None + assert client.content_requests == [] + assert result["changed_files"] == [] + assert not (tmp_path / ".python-version").exists() + + +@pytest.mark.parametrize("store_kind", ["file", "executor"]) +def test_exact_file_target_uses_its_deployment_record_and_parent_metadata(tmp_path, store_kind): + project = tmp_path / "project" + project.mkdir() + content_file = project / "report.ipynb" + content_file.write_text("{}", encoding="utf-8") + (project / ".python-version").write_text("3.11\n", encoding="utf-8") + if store_kind == "file": + save_deployment_record(content_file, "saved-guid") + record = None + store = None + else: + record = {"app_id": "saved-guid", "app_guid": "saved-guid"} + store = None + + executor, client, _ = make_executor( + project, + content={"saved-guid": {"py_version": "3.11.8"}}, + record=record, + store=store, + ) + + result = run_preflight(executor, str(content_file), fix=True) + + assert result["status"] == "ok" + assert result["runtime"] == "python" + assert result["python_requires"] == "~=3.11.0" + assert result["existing_content"]["app_id"] == "saved-guid" + assert client.content_requests == ["saved-guid"] + assert result["changed_files"] == [] + assert (project / ".python-version").read_text(encoding="utf-8") == "3.11\n" + + +def test_appstore_config_fallback_record_is_used(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + module = Path(fake_module_file_from_directory(str(project))) + config_dir = tmp_path / "user-config" + monkeypatch.setattr("rsconnect.metadata.config_dirname", lambda: str(config_dir)) + fallback = config_dir / "applications" / f"{sha1(os.path.abspath(module))}.json" + fallback.parent.mkdir(parents=True) + fallback.write_text(json.dumps({SERVER_URL: {"app_id": "saved-id", "app_guid": "saved-guid"}})) + executor, client, _ = make_executor( + project, + content={"saved-guid": {"py_version": "3.12.1"}}, + ) + + result = run_preflight(executor, str(project)) + + assert client.content_requests == ["saved-guid"] + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] == "saved-guid" + + +def test_new_overrides_local_deployment_record(tmp_path): + executor, client, store = make_executor( + tmp_path, + new=True, + record={"app_id": "saved-id"}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert store.requests == [] + assert client.content_requests == [] + assert result["existing_content"]["exists"] is False + + +def test_empty_server_availability_is_unknown_without_mutation(tmp_path): + executor, _, _ = make_executor(tmp_path, new=True, settings={"installations": []}) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["publishable_python_versions"] == [] + assert result["changed_files"] == [] + assert not (tmp_path / ".python-version").exists() + + +def test_python_settings_failure_is_unknown_and_actionable(tmp_path, monkeypatch): + executor, client, _ = make_executor(tmp_path, new=True) + + def fail_settings(): + raise RSConnectException("settings unavailable", status=503) + + monkeypatch.setattr(client, "python_settings", fail_settings) + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert any("Could not read server Python settings" in warning for warning in result["warnings"]) + assert any("Check the server's Python installations" in action for action in result["actions"]) + assert not (tmp_path / ".python-version").exists() + + +@pytest.mark.parametrize( + ("status_code", "action_text"), + [ + (404, "--new"), + (403, "access"), + (None, "Connect access"), + ], +) +def test_unreadable_redeploy_content_is_unknown_with_recovery_action(tmp_path, monkeypatch, status_code, action_text): + executor, client, _ = make_executor( + tmp_path, + record={"app_id": "saved-guid"}, + settings=DEFAULT_SETTINGS, + ) + + def fail_read(_app_id: str): + raise RSConnectException("content lookup failed", status=status_code) + + monkeypatch.setattr(client, "get_content_by_id", fail_read) + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] == "saved-guid" + assert any(action_text in action for action in result["actions"]) + if status_code == 404: + assert any("--new" in action for action in result["actions"]) + + +def test_invalid_content_response_is_unknown(tmp_path): + executor, _, _ = make_executor( + tmp_path, + record={"app_id": "saved-guid"}, + content={"saved-guid": None}, # type: ignore[dict-item] + ) + + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "unknown" + assert any("invalid response" in warning for warning in result["warnings"]) + assert result["actions"] + + +def test_missing_publishable_flags_are_unknown_legacy_permissibility(tmp_path): + executor, _, _ = make_executor( + tmp_path, + new=True, + settings={"installations": [{"version": "3.12.8"}]}, + ) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["local_python_publishable"] is None + assert result["publishable_python_versions"] == [] + assert result["changed_files"] == [] + assert any("legacy permissibility is unknown" in warning for warning in result["warnings"]) + + +def test_invalid_python_version_is_preserved_and_content_result_is_assigned(tmp_path): + pin = tmp_path / ".python-version" + pin.write_text("") + executor, _, _ = make_executor(tmp_path, new=True) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is False + assert result["changed_files"] == [] + assert pin.read_text() == "" + assert any("unusable Python requirement" in warning for warning in result["warnings"]) + + +@pytest.mark.parametrize( + "metadata_bytes", + [ + b"[project\nrequires-python = '>=3.12'\n", + b"\xff", + ], +) +def test_unreadable_python_metadata_is_unknown_and_actionable(tmp_path, metadata_bytes): + (tmp_path / "pyproject.toml").write_bytes(metadata_bytes) + executor, _, _ = make_executor(tmp_path, new=True) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert result["actions"] + assert not (tmp_path / ".python-version").exists() + + +def test_metadata_permission_error_is_unknown(tmp_path, monkeypatch): + def deny_read(_project: str): + raise PermissionError("permission denied") + + monkeypatch.setattr("rsconnect.preflight.detect_python_version_requirement", deny_read) + executor, _, _ = make_executor(tmp_path, new=True) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert any("permission denied" in warning for warning in result["warnings"]) + assert result["actions"] + assert not (tmp_path / ".python-version").exists() + + +def test_metadata_programming_error_is_not_hidden_as_unknown(tmp_path, monkeypatch): + def bug(_project: str): + raise RuntimeError("unexpected implementation failure") + + monkeypatch.setattr("rsconnect.preflight.detect_python_version_requirement", bug) + executor, _, _ = make_executor(tmp_path, new=True) + + with pytest.raises(RuntimeError, match="unexpected implementation failure"): + run_preflight(executor, str(tmp_path), fix=True) + + +@pytest.mark.parametrize( + ("filename", "contents", "expected_constraint"), + [ + ("pyproject.toml", '[project]\nrequires-python = ""\n', None), + ("pyproject.toml", '[project]\nrequires-python = "not-a-specifier"\n', "not-a-specifier"), + ("setup.cfg", "[options]\npython_requires =\n", None), + ("setup.cfg", "[options]\npython_requires = not-a-specifier\n", "not-a-specifier"), + ], +) +def test_invalid_declared_requirement_is_not_masked_by_fix(tmp_path, filename, contents, expected_constraint): + metadata = tmp_path / filename + metadata.write_text(contents) + executor, _, _ = make_executor(tmp_path, new=True) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["python_requires"] == expected_constraint + assert result["changed_files"] == [] + assert not (tmp_path / ".python-version").exists() + assert any(filename in warning and "unusable Python requirement" in warning for warning in result["warnings"]) + assert any(filename in action and "Repair or remove" in action for action in result["actions"]) + + +def test_fix_exclusively_creates_pin_and_rechecks_constraint(tmp_path): + executor, _, store = make_executor( + tmp_path, + new=True, + record={"app_id": "must-not-be-read"}, + settings={"installations": [{"version": "3.13.4", "publishable": True}]}, + ) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert store.requests == [] + assert result["changed_files"] == [".python-version"] + assert (tmp_path / ".python-version").read_text() == "3.13\n" + assert result["python_requires"] == "~=3.13.0" + assert result["status"] == "ok" + + +def test_unpinned_local_version_mismatch_is_unknown_until_pin_applied(tmp_path, monkeypatch): + monkeypatch.setattr("rsconnect.preflight.platform.python_version", lambda: "3.13.2") + executor, _, _ = make_executor( + tmp_path, + new=True, + settings={"installations": [{"version": "3.12.8", "publishable": True}]}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "unknown" + assert result["recommended_python"] == "3.12" + assert result["changed_files"] == [] + assert any("dependency compatibility has not been tested" in warning for warning in result["warnings"]) + + +def test_existing_python_version_pin_warns_when_local_python_does_not_match(tmp_path): + (tmp_path / ".python-version").write_text("3.11\n", encoding="utf-8") + executor, _, _ = make_executor( + tmp_path, + record={"app_id": "saved-guid"}, + content={"saved-guid": {"py_version": "3.11.8"}}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "ok" + assert any( + "Local Python 3.12.2 does not satisfy the project requirement ~=3.11.0" in warning + for warning in result["warnings"] + ) + + +def test_existing_python_version_pin_that_matches_local_has_no_mismatch_warning(tmp_path): + (tmp_path / ".python-version").write_text("3.12\n", encoding="utf-8") + executor, _, _ = make_executor(tmp_path, new=True) + + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "ok" + assert not any("does not satisfy the project requirement" in warning for warning in result["warnings"]) + + +def test_unknown_redeploy_has_an_action_even_without_python_constraint(tmp_path): + executor, _, _ = make_executor( + tmp_path, + settings={"installations": []}, + record={"app_id": "saved-guid"}, + ) + + result = run_preflight(executor, str(tmp_path)) + + assert result["status"] == "unknown" + assert result["actions"] + assert any("existing content ID" in action for action in result["actions"]) + + +@pytest.mark.parametrize( + "local, versions, expected", + [ + ("3.12.2", ["3.11.8", "3.12.9", "3.13.1"], "3.12"), + ("3.12.2", ["3.10.8", "3.14.1"], "3.14"), + ("3.14.2", ["3.10.8", "3.13.1"], "3.13"), + ], +) +def test_fix_uses_helper_pin_selection(tmp_path, monkeypatch, local, versions, expected): + monkeypatch.setattr("rsconnect.preflight.platform.python_version", lambda: local) + executor, _, _ = make_executor( + tmp_path, + new=True, + settings={"installations": [{"version": version, "publishable": True} for version in versions]}, + ) + + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["recommended_python"] == expected + assert (tmp_path / ".python-version").read_text() == expected + "\n" + assert result["status"] == "ok" + + +def test_connect_cloud_is_rejected(tmp_path): + cloud = ConnectCloudServer("account", url="https://api.connect.posit.cloud/v1") + executor, _, _ = make_executor(tmp_path, server=cloud) + + with pytest.raises(RSConnectException, match="not Connect Cloud"): + run_preflight(executor, str(tmp_path)) + + +def test_spcs_executor_is_supported(tmp_path): + server = SPCSConnectServer(SERVER_URL, "api-key", "snowflake-connection") + executor, _, _ = make_executor(tmp_path, server=server) + + result = run_preflight(executor, str(tmp_path)) + + assert result["server"] == SERVER_URL + assert result["status"] == "ok" + + +def test_metadata_symlink_is_reported_without_reading_or_fixing(tmp_path, monkeypatch): + target = tmp_path / "real-python-version" + target.write_text("3.12\n") + pin = tmp_path / ".python-version" + try: + pin.symlink_to(target) + except (NotImplementedError, OSError): + pytest.skip("symlinks are unavailable") + executor, _, _ = make_executor(tmp_path, new=True) + + def unexpected_read(_project: str): + pytest.fail("symlinked Python metadata must not be read") + + monkeypatch.setattr("rsconnect.preflight.detect_python_version_requirement", unexpected_read) + result = run_preflight(executor, str(tmp_path), fix=True) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert result["actions"] + assert any("is a symlink; refusing to read it" in warning for warning in result["warnings"]) + assert pin.is_symlink() diff --git a/tests/test_preflight_integration.py b/tests/test_preflight_integration.py new file mode 100644 index 00000000..1adc6bbd --- /dev/null +++ b/tests/test_preflight_integration.py @@ -0,0 +1,220 @@ +"""Optional live Posit Connect integration tests for the preflight CLI.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List + +import pytest + +from rsconnect.api import RSConnectClient, RSConnectServer +from rsconnect.certificates import read_certificate_file +from rsconnect.exception import RSConnectException + +from .utils import optional_ca_data, require_api_key, require_connect + + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Agent login and preflight require POSIX.") + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_TRUE_VALUES = {"1", "true", "yes", "on"} + + +def _insecure() -> bool: + return os.environ.get("CONNECT_INSECURE", "").strip().lower() in _TRUE_VALUES + + +def _live_client(server_url: str, api_key: str) -> RSConnectClient: + ca_path = optional_ca_data() + ca_data = read_certificate_file(ca_path) if ca_path else None + return RSConnectClient(RSConnectServer(server_url, api_key, insecure=_insecure(), ca_data=ca_data)) + + +def _cli_environment(home: Path, api_key: str) -> Dict[str, str]: + home.mkdir(parents=True, exist_ok=True) + environment = os.environ.copy() + for name in ( + "CONNECT_SERVER", + "CONNECT_INSECURE", + "CONNECT_CA_CERTIFICATE", + "CONNECT_IDENTITY_TOKEN", + "CONNECT_IDENTITY_TOKEN_FILE", + "CONNECT_CLOUD_ACCOUNT", + "CONNECT_CLOUD_CLIENT_ID", + "CONNECT_CLOUD_CLIENT_SECRET", + "SHINYAPPS_ACCOUNT", + "RSCONNECT_E2E_CLOUD_BASE_URL", + ): + environment.pop(name, None) + environment.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / "xdg"), + "APPDATA": str(home / "appdata"), + "CONNECT_API_KEY": api_key, + "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring", + "RSCONNECT_DISABLE_VERSION_CHECK": "1", + } + ) + return environment + + +def _run_preflight( + server_url: str, + api_key: str, + project: Path, + home: Path, + runtime: str | None = None, +) -> subprocess.CompletedProcess[str]: + arguments = [ + sys.executable, + "-m", + "rsconnect.main", + "preflight", + "--server", + server_url, + ] + ca_path = optional_ca_data() + if ca_path: + arguments.extend(["--cacert", ca_path]) + if _insecure(): + arguments.append("--insecure") + arguments.extend([str(project), "--new"]) + if runtime: + arguments.extend(["--runtime", runtime]) + return subprocess.run( + arguments, + cwd=str(_REPO_ROOT), + env=_cli_environment(home, api_key), + capture_output=True, + text=True, + timeout=60, + ) + + +def _report(result: subprocess.CompletedProcess[str]) -> Dict[str, Any]: + try: + report = json.loads(result.stdout) + except json.JSONDecodeError as err: + pytest.fail( + f"The preflight CLI did not return JSON (exit {result.returncode}): {result.stdout}{result.stderr}: {err}" + ) + assert isinstance(report, dict), result.stdout + return report + + +def _files(project: Path) -> Dict[str, bytes]: + return {path.name: path.read_bytes() for path in project.iterdir()} + + +def test_live_python_preflight_checks_a_server_runtime_without_writing_project_files(tmp_path: Path) -> None: + server_url = require_connect() + api_key = require_api_key() + settings = _live_client(server_url, api_key).python_settings() + installations = settings.get("installations") + assert isinstance(installations, list), settings + publishable_versions = [ + item["version"] + for item in installations + if isinstance(item, dict) and item.get("publishable") is True and isinstance(item.get("version"), str) + ] + if not publishable_versions: + pytest.skip("Configured Connect server has no publishable Python runtime to check.") + + requirement = "==" + publishable_versions[0] + project = tmp_path / "python-project" + project.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname = "preflight-live-python"\nversion = "1.0.0"\nrequires-python = "%s"\n' % requirement, + encoding="utf-8", + ) + before = _files(project) + + result = _run_preflight(server_url, api_key, project, tmp_path / "home") + report = _report(result) + + assert result.returncode == 0, result.stdout + result.stderr + assert report["status"] == "ok" + assert report["server"].rstrip("/") == server_url.rstrip("/") + assert report["python_requires"] == requirement + assert report["publishable_python_versions"] == publishable_versions + assert report["changed_files"] == [] + assert _files(project) == before + + +def test_live_nodejs_preflight_checks_server_settings_without_writing_project_files(tmp_path: Path) -> None: + server_url = require_connect() + api_key = require_api_key() + try: + settings = _live_client(server_url, api_key).nodejs_settings() + except RSConnectException as err: + if err.status == 404: + pytest.skip(f"Configured Connect server does not expose Node.js settings (HTTP 404): {err}") + raise + + installations = settings.get("installations") + assert isinstance(installations, list), settings + versions: List[str] = [ + item["version"] for item in installations if isinstance(item, dict) and isinstance(item.get("version"), str) + ] + publishable_versions = [ + item["version"] + for item in installations + if isinstance(item, dict) and item.get("publishable") is True and isinstance(item.get("version"), str) + ] + requirement = publishable_versions[0] if publishable_versions else (versions[0] if versions else "*") + + project = tmp_path / "node-project" + project.mkdir() + (project / "package.json").write_text( + json.dumps( + { + "name": "preflight-live-node", + "version": "1.0.0", + "engines": {"node": requirement}, + } + ), + encoding="utf-8", + ) + (project / "package-lock.json").write_text( + json.dumps( + { + "name": "preflight-live-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": True, + "packages": {"": {"name": "preflight-live-node", "version": "1.0.0"}}, + } + ), + encoding="utf-8", + ) + before = _files(project) + + result = _run_preflight(server_url, api_key, project, tmp_path / "home", runtime="nodejs") + report = _report(result) + if report["local_node"] is None: + diagnostic = next( + ( + warning + for warning in report["warnings"] + if "Could not inspect local Node.js/npm prerequisites" in warning + ), + None, + ) + assert diagnostic is not None, report + pytest.skip(f"Local Node.js/npm is unavailable: {diagnostic}") + + assert not any("Could not read server Node.js settings" in warning for warning in report["warnings"]), report + assert result.returncode == (3 if report["status"] == "incompatible" else 0), result.stdout + result.stderr + assert report["runtime"] == "nodejs" + assert report["server"].rstrip("/") == server_url.rstrip("/") + assert report["node_requires"] == requirement + assert report["nodejs_enabled"] == settings.get("enabled") + assert report["nodejs_status"] == settings.get("status") + assert report["publishable_node_versions"] == publishable_versions + assert report["changed_files"] == [] + assert _files(project) == before diff --git a/tests/test_preflight_node.py b/tests/test_preflight_node.py new file mode 100644 index 00000000..92dd16ab --- /dev/null +++ b/tests/test_preflight_node.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import json +import locale +import os +import shlex +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from rsconnect.api import ( + ConnectCloudServer, + RSConnectClient, + RSConnectExecutor, + RSConnectServer, + SPCSConnectServer, +) +from rsconnect.exception import RSConnectException +from rsconnect.environment import fake_module_file_from_directory +from rsconnect.preflight_node import ( + _evaluate_node_range, + _npm_cli_path, + _parse_node_evaluation, + run_node_preflight, +) + + +pytestmark = pytest.mark.skipif(os.name != "posix", reason="Agent login and preflight require POSIX.") + +SERVER_URL = "https://connect.example.test" +DEFAULT_SETTINGS = { + "enabled": True, + "status": {"enabled": True}, + "installations": [ + {"version": "22.4.1", "publishable": True}, + {"version": "20.18.0", "publishable": False}, + ], +} +GO_RUNTIME_SETTINGS = { + "enabled": True, + "status": {"enabled": True, "licensed": True, "available": True, "usable": True}, + "installations": [{"version": "22.4.1", "publishable": True}], +} + + +class FakeClient(RSConnectClient): + def __init__( + self, + settings: Any = DEFAULT_SETTINGS, + content: dict[str, dict[str, Any]] | None = None, + settings_error: RSConnectException | None = None, + ): + self.settings = settings + self.content = content or {} + self.settings_error = settings_error + self.content_requests: list[str] = [] + + def nodejs_settings(self): + if self.settings_error: + raise self.settings_error + return self.settings + + def get_content_by_id(self, app_id: str): + self.content_requests.append(app_id) + return self.content.get(app_id, {}) + + +class FakeStore: + def __init__(self, record: dict[str, Any] | None = None): + self.record = record + self.requests: list[str] = [] + + def get(self, server_url: str): + self.requests.append(server_url) + return self.record + + +def make_executor( + *, + settings: Any = DEFAULT_SETTINGS, + content: dict[str, dict[str, Any]] | None = None, + record: dict[str, Any] | None = None, + app_id: str | None = None, + new: bool = False, + store: FakeStore | None = None, + server: Any = None, + settings_error: RSConnectException | None = None, + path: Path | None = None, +) -> tuple[RSConnectExecutor, FakeClient, FakeStore]: + client = FakeClient(settings, content, settings_error) + app_store = store or FakeStore() + executor = cast(Any, RSConnectExecutor.__new__(RSConnectExecutor)) + executor.remote_server = server or RSConnectServer(SERVER_URL, "api-key") + executor.client = client + executor.app_store = app_store + executor.path = str(path or Path.cwd()) + executor.app_id = app_id + executor.new = new + if record is not None: + if path is None: + raise AssertionError("a project path is required for a saved deployment record") + module_file = Path(fake_module_file_from_directory(str(path))) + metadata_path = module_file.parent / "rsconnect-python" / f"{module_file.stem}.json" + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(json.dumps({executor.record_server_key(): record}), encoding="utf-8") + return cast(RSConnectExecutor, executor), client, app_store + + +def make_project(directory: Path, package: Any = None) -> Path: + directory.mkdir(parents=True, exist_ok=True) + contents = {"name": "demo", "engines": {"node": "^20.0.0"}} if package is None else package + (directory / "package.json").write_text(json.dumps(contents), encoding="utf-8") + (directory / "package-lock.json").write_text("{}", encoding="utf-8") + return directory + + +def fake_preflight_tools(monkeypatch, matches: bool = True): + calls: list[tuple[str, str | None]] = [] + + def create(project: str, node_executable: str | None = None): + calls.append((project, node_executable)) + return SimpleNamespace(node_version="22.22.1") + + def evaluate(node: str, requirement: str, versions: list[str]): + return { + "range_valid": True, + "valid_versions": [True] * len(versions), + "matches": [matches] * len(versions), + }, None + + monkeypatch.setattr("rsconnect.preflight_node.NodeEnvironment", SimpleNamespace(create=create)) + monkeypatch.setattr("rsconnect.preflight_node._evaluate_node_range", evaluate) + return calls + + +def disable_local_node_tools(monkeypatch): + def missing_node(project, node_executable=None): + raise RSConnectException("Could not find npm") + + monkeypatch.setattr("rsconnect.preflight_node.NodeEnvironment", SimpleNamespace(create=missing_node)) + monkeypatch.setattr("rsconnect.preflight_node._npm_cli_path", lambda: None) + + +def test_new_content_reports_node_runtime_fields_and_does_not_mutate_project(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + before = {path.name: path.read_bytes() for path in project.iterdir()} + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result == { + "status": "ok", + "runtime": "nodejs", + "server": SERVER_URL, + "local_node": "22.22.1", + "node_requires": "^20.0.0", + "server_node_versions": ["22.4.1", "20.18.0"], + "publishable_node_versions": ["22.4.1"], + "nodejs_enabled": True, + "nodejs_status": {"enabled": True}, + "existing_content": { + "exists": False, + "app_id": None, + "node_version": None, + "compatibility": "not_applicable", + }, + "changed_files": [], + "warnings": [], + "actions": [], + } + assert {path.name: path.read_bytes() for path in project.iterdir()} == before + assert not any("python" in key for key in result) + + +def test_missing_engines_node_means_any_publishable_installed_version(tmp_path, monkeypatch): + project = make_project(tmp_path / "app", {"name": "demo"}) + calls = fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "ok" + assert result["node_requires"] is None + assert result["publishable_node_versions"] == ["22.4.1"] + assert calls == [(str(project), None)] + + +def test_new_content_ignores_installed_but_unpublishable_versions(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={ + "enabled": True, + "status": {}, + "installations": [{"version": "20.18.0", "publishable": False}], + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "incompatible" + assert result["publishable_node_versions"] == [] + + +def test_redeploy_uses_all_installed_versions_and_current_version_is_informational(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, client, store = make_executor( + path=project, + content={"saved-guid": {"node_version": "18.17.0"}}, + record={"app_id": "saved-id", "app_guid": "saved-guid"}, + settings={ + "enabled": True, + "status": {"enabled": True}, + "installations": [{"version": "20.18.0", "publishable": False}], + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert store.requests == [] + assert client.content_requests == ["saved-guid"] + assert result["status"] == "ok" + assert result["existing_content"] == { + "exists": True, + "app_id": "saved-guid", + "node_version": "18.17.0", + "compatibility": "compatible", + } + + +@pytest.mark.parametrize( + ("content_status", "settings"), + [ + pytest.param(403, DEFAULT_SETTINGS, id="forbidden"), + pytest.param(404, DEFAULT_SETTINGS, id="not-found"), + pytest.param( + 404, + {"enabled": True, "status": {"state": "ready"}, "installations": []}, + id="not-found-with-server-incompatibility", + ), + ], +) +def test_unreadable_existing_content_is_unknown(tmp_path, monkeypatch, content_status, settings): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, client, _ = make_executor(path=project, record={"app_guid": "saved-guid"}, settings=settings) + + def inaccessible_content(app_id): + raise RSConnectException("Content lookup failed.", status=content_status) + + monkeypatch.setattr(client, "get_content_by_id", inaccessible_content) + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["existing_content"]["compatibility"] == "unknown" + if content_status == 404: + assert any("--new" in action for action in result["actions"]) + else: + assert any("permission" in action.lower() for action in result["actions"]) + + +def test_empty_server_installations_are_incompatible(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={"enabled": True, "status": {}, "installations": []}, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "incompatible" + assert result["publishable_node_versions"] == [] + + +@pytest.mark.parametrize( + "installations", + [ + pytest.param([], id="no-installed-versions"), + pytest.param([{"version": "22.4.1", "publishable": False}], id="none-publishable"), + ], +) +def test_complete_empty_publishable_set_is_incompatible_without_local_node_or_npm(tmp_path, monkeypatch, installations): + project = make_project(tmp_path / "app") + disable_local_node_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={"enabled": True, "status": {"state": "ready"}, "installations": installations}, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["local_node"] is None + assert result["status"] == "incompatible" + assert result["publishable_node_versions"] == [] + + +def test_incomplete_empty_publishable_set_remains_unknown_without_local_node_or_npm(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + disable_local_node_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={ + "enabled": True, + "status": {"state": "ready"}, + "installations": [{"version": "22.4.1"}], + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["local_node"] is None + assert result["status"] == "unknown" + assert any("did not report whether Node.js 22.4.1 is publishable" in warning for warning in result["warnings"]) + + +def test_unresolved_target_overrides_empty_server_candidate_set(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + disable_local_node_tools(monkeypatch) + executor, _, _ = make_executor( + path=project, + record={}, + settings={"enabled": True, "status": {"state": "ready"}, "installations": []}, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["existing_content"]["exists"] is True + assert result["existing_content"]["app_id"] is None + assert any("no content ID" in warning for warning in result["warnings"]) + assert any("--app-id" in action for action in result["actions"]) + + +def test_missing_publishable_flag_is_unknown_for_new_content(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={ + "enabled": True, + "status": {}, + "installations": [{"version": "22.4.1"}], + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert any("did not report whether Node.js 22.4.1 is publishable" in warning for warning in result["warnings"]) + + +@pytest.mark.parametrize( + ("field", "value", "expected_status", "expected_action"), + [ + pytest.param("enabled", False, "incompatible", "enable", id="disabled-status-flag"), + pytest.param("licensed", False, "incompatible", "license", id="unlicensed"), + pytest.param("available", False, "incompatible", "available", id="unavailable"), + pytest.param("usable", False, "incompatible", "usable", id="unusable"), + pytest.param("configured", False, "incompatible", "configure", id="unconfigured-alias"), + pytest.param("licensed", "false", "unknown", None, id="malformed-license-flag"), + pytest.param("available", None, "unknown", None, id="malformed-availability-flag"), + ], +) +def test_nodejs_status_flags_distinguish_false_from_malformed( + tmp_path, monkeypatch, field, value, expected_status, expected_action +): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={ + **GO_RUNTIME_SETTINGS, + "status": {**GO_RUNTIME_SETTINGS["status"], field: value}, + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == expected_status + if expected_action: + assert any(expected_action in action.lower() for action in result["actions"]) + else: + assert any("invalid Node.js" in warning for warning in result["warnings"]) + + +def test_go_runtime_status_shape_is_compatible_when_all_flags_are_true(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True, settings=GO_RUNTIME_SETTINGS) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "ok" + assert result["nodejs_status"] == GO_RUNTIME_SETTINGS["status"] + + +@pytest.mark.parametrize("status", [None, {}, {"state": "ready"}]) +def test_missing_nodejs_status_flags_are_actionable_unknown(tmp_path, monkeypatch, status): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={**DEFAULT_SETTINGS, "status": status}, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert any("did not return Node.js runtime status flags" in warning for warning in result["warnings"]) + assert any("status flags" in action for action in result["actions"]) + + +def test_missing_status_flags_do_not_hide_a_proven_version_mismatch(tmp_path, monkeypatch): + project = make_project(tmp_path / "app", {"name": "demo", "engines": {"node": "^99.0.0"}}) + fake_preflight_tools(monkeypatch, matches=False) + executor, _, _ = make_executor( + new=True, + settings={ + "enabled": True, + "status": {}, + "installations": [{"version": "22.4.1", "publishable": True}], + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "incompatible" + assert any("No publishable server Node.js version" in warning for warning in result["warnings"]) + + +@pytest.mark.parametrize( + "settings", + [ + {"status": {}, "installations": [{"version": "22.4.1", "publishable": True}]}, + {"enabled": True, "installations": [{"version": "22.4.1", "publishable": True}]}, + ], +) +def test_missing_server_nodejs_metadata_is_unknown(tmp_path, monkeypatch, settings): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True, settings=settings) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["nodejs_enabled"] is settings.get("enabled") + assert result["nodejs_status"] == settings.get("status") + + +def test_explicitly_disabled_nodejs_is_incompatible(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={ + "enabled": False, + "status": {"reason": "license", "licensed": False}, + "installations": [], + }, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "incompatible" + assert result["nodejs_enabled"] is False + assert any("Enable Node.js" in action for action in result["actions"]) + assert any("license" in action.lower() for action in result["actions"]) + + +@pytest.mark.parametrize("enabled_field", [{}, {"enabled": None}, {"enabled": "true"}]) +def test_missing_or_invalid_top_level_enabled_is_actionable_unknown(tmp_path, monkeypatch, enabled_field): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + settings = {key: value for key, value in DEFAULT_SETTINGS.items() if key != "enabled"} + settings.update(enabled_field) + executor, _, _ = make_executor(new=True, settings=settings) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert "Check whether Node.js is enabled in the Connect server settings." in result["actions"] + + +def test_unsupported_settings_endpoint_is_unknown(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings_error=RSConnectException("HTTP 404"), + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["nodejs_enabled"] is None + assert any("HTTP 404" in warning for warning in result["warnings"]) + assert result["actions"].count("Check whether this Connect server exposes Node.js runtime settings.") == 1 + + +@pytest.mark.parametrize( + "package", + [ + [], + {"name": "demo", "engines": []}, + {"name": "demo", "engines": {"node": 22}}, + ], +) +def test_malformed_package_metadata_is_unknown_and_unchanged(tmp_path, monkeypatch, package): + project = make_project(tmp_path / "app", package) + before = (project / "package.json").read_bytes() + fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert (project / "package.json").read_bytes() == before + assert result["actions"] + + +def test_invalid_npm_range_is_unknown_with_repair_action(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + monkeypatch.setattr( + "rsconnect.preflight_node._evaluate_node_range", + lambda node, requirement, versions: ( + {"range_valid": False, "valid_versions": [True] * len(versions), "matches": []}, + None, + ), + ) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert any("not a valid npm semver range" in warning for warning in result["warnings"]) + assert any("Repair package.json engines.node" in action for action in result["actions"]) + + +def test_missing_local_node_tools_are_unknown(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + + def missing_tools(project, node_executable=None): + raise RSConnectException("Could not find npm") + + monkeypatch.setattr("rsconnect.preflight_node.NodeEnvironment", SimpleNamespace(create=missing_tools)) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["local_node"] is None + assert any("Could not find npm" in warning for warning in result["warnings"]) + + +def test_missing_lockfile_does_not_hide_a_proven_server_version_mismatch(tmp_path): + if not shutil.which("node") or not shutil.which("npm") or not _npm_cli_path(): + pytest.skip("Local Node.js/npm are required to evaluate a real npm range.") + project = make_project(tmp_path / "app", {"name": "demo", "engines": {"node": "^99.0.0"}}) + (project / "package-lock.json").unlink() + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["local_node"] is None + assert result["status"] == "incompatible" + assert result["node_requires"] == "^99.0.0" + assert any("package-lock.json" in warning for warning in result["warnings"]) + assert any("No publishable server" in warning for warning in result["warnings"]) + assert result["changed_files"] == [] + + +def test_non_utf8_package_is_reported_without_a_traceback_or_mutation(tmp_path): + project = make_project(tmp_path / "app") + invalid_bytes = b"\xff" + (project / "package.json").write_bytes(invalid_bytes) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert any("Could not read package.json" in warning for warning in result["warnings"]) + assert result["changed_files"] == [] + assert (project / "package.json").read_bytes() == invalid_bytes + + +def test_non_executable_node_is_reported_as_a_local_prerequisite_failure(tmp_path): + project = make_project(tmp_path / "app") + executable = tmp_path / "non-executable-node" + executable.write_text("not an executable", encoding="utf-8") + executable.chmod(0o600) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project), node=str(executable)) + + assert result["status"] == "unknown" + assert result["local_node"] is None + assert any("Could not inspect local Node.js/npm prerequisites" in warning for warning in result["warnings"]) + assert result["changed_files"] == [] + + +def test_missing_npm_semver_module_is_unknown(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + monkeypatch.setattr( + "rsconnect.preflight_node._evaluate_node_range", + lambda node, requirement, versions: (None, "Could not load npm's semver module."), + ) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert any("semver module" in warning for warning in result["warnings"]) + + +@pytest.mark.parametrize("stream", ["stdout", "stderr"]) +def test_non_utf8_node_evaluator_output_is_reported_as_unknown(tmp_path, monkeypatch, stream): + node = shutil.which("node") + if not node: + pytest.skip("Node.js is required to verify evaluator output decoding.") + + project = make_project(tmp_path / "app") + monkeypatch.setattr( + "rsconnect.preflight_node.NodeEnvironment", + SimpleNamespace(create=lambda project, node_executable=None: SimpleNamespace(node_version="22.22.1")), + ) + monkeypatch.setattr(locale, "getpreferredencoding", lambda do_setlocale=True: "utf-8") + monkeypatch.setattr("rsconnect.preflight_node._npm_cli_path", lambda: "npm-cli.js") + monkeypatch.setattr( + "rsconnect.preflight_node._SEMVER_SCRIPT", + f"process.{stream}.write(Buffer.from([0xff]));", + ) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project), node=node) + + assert result["status"] == "unknown" + assert any("Could not run npm's semver evaluator:" in warning for warning in result["warnings"]) + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param( + {"range_valid": True, "valid_versions": [True], "matches": []}, + id="missing-match-result", + ), + pytest.param( + {"range_valid": True, "valid_versions": [True], "matches": [True, False]}, + id="extra-match-result", + ), + pytest.param( + {"range_valid": True, "valid_versions": [1], "matches": [True]}, + id="non-boolean-valid-version", + ), + pytest.param( + {"range_valid": True, "valid_versions": [True], "matches": [1]}, + id="non-boolean-match", + ), + pytest.param( + {"range_valid": False, "valid_versions": [True], "matches": [False]}, + id="invalid-range-with-match-results", + ), + ], +) +def test_node_evaluation_rejects_malformed_version_results(payload): + evaluation, error = _parse_node_evaluation(json.dumps(payload), version_count=1) + + assert evaluation is None + assert error == "npm's semver evaluator returned incomplete version results." + + +def test_node_evaluation_accepts_empty_matches_for_invalid_range(): + payload = {"range_valid": False, "valid_versions": [True, False], "matches": []} + + evaluation, error = _parse_node_evaluation(json.dumps(payload), version_count=2) + + assert error is None + assert evaluation == payload + + +def test_custom_node_executable_is_used(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + calls = fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project), node="/opt/node/bin/node") + + assert result["local_node"] == "22.22.1" + assert calls == [(str(project), "/opt/node/bin/node")] + + +@pytest.mark.parametrize("filename", ["app.js", "manifest.json"]) +def test_preflight_accepts_a_project_file_and_checks_its_parent(tmp_path, monkeypatch, filename): + project = make_project(tmp_path / "app") + project_file = project / filename + project_file.write_text("{}", encoding="utf-8") + calls = fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project_file)) + + assert result["status"] == "ok" + assert calls == [(str(project), None)] + + +@pytest.mark.parametrize("filename", ["package.json", "package-lock.json"]) +def test_package_metadata_symlinks_are_unknown_and_never_opened(tmp_path, monkeypatch, filename): + project = make_project(tmp_path / "app") + target = tmp_path / "target" + target.write_text("{}") + path = project / filename + path.unlink() + try: + path.symlink_to(target) + except (NotImplementedError, OSError): + pytest.skip("symlinks are unavailable") + calls = fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor(new=True) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "unknown" + assert result["changed_files"] == [] + assert calls == [] + assert any(f"{filename} is a symlink" in warning for warning in result["warnings"]) + assert any(filename in action for action in result["actions"]) + + +def test_symlinked_metadata_does_not_hide_a_disabled_server(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + package_path = project / "package.json" + package_path.unlink() + try: + package_path.symlink_to(tmp_path / "target") + except (NotImplementedError, OSError): + pytest.skip("symlinks are unavailable") + calls = fake_preflight_tools(monkeypatch) + executor, _, _ = make_executor( + new=True, + settings={"enabled": False, "status": {}, "installations": []}, + ) + + result = run_node_preflight(executor, str(project)) + + assert result["status"] == "incompatible" + assert calls == [] + + +def test_spcs_is_supported_and_connect_cloud_is_rejected(tmp_path, monkeypatch): + project = make_project(tmp_path / "app") + fake_preflight_tools(monkeypatch) + spcs = SPCSConnectServer(SERVER_URL, "api-key", "snowflake-connection") + executor, _, _ = make_executor(new=True, server=spcs) + result = run_node_preflight(executor, str(project)) + assert result["server"] == SERVER_URL + + cloud = ConnectCloudServer("account", url="https://api.connect.posit.cloud/v1") + cloud_executor, _, _ = make_executor(server=cloud) + with pytest.raises(RSConnectException, match="supports self-hosted"): + run_node_preflight(cloud_executor, str(project)) + + +@pytest.mark.parametrize( + ("requirement", "version", "expected"), + [ + ("^20.0.0", "20.8.1", True), + ("~20.2.0", "20.2.9", True), + (">=20.0.0 <21", "20.18.0", True), + ("20.x || 22.x", "22.4.1", True), + ("*", "v22.4.1", True), + ("^22.0.0-0", "22.0.0-rc.2", True), + ("^22.0.0", "22.0.0-rc.2", False), + ], +) +def test_ranges_use_local_npm_semver_when_available(requirement, version, expected): + node = shutil.which("node") + if not node or not _npm_cli_path(): + pytest.skip("Node.js and npm's semver module are unavailable") + + result, error = _evaluate_node_range(node, requirement, [version]) + + if error: + pytest.skip(error) + assert result is not None + assert result["range_valid"] is True + assert result["valid_versions"] == [True] + assert result["matches"] == [expected] + + +def test_npm_shim_cannot_supply_global_semver(tmp_path, monkeypatch): + node = shutil.which("node") + if not node: + pytest.skip("Node.js is required to verify the npm shim behavior.") + + bin_directory = tmp_path / "volta" / "bin" + bin_directory.mkdir(parents=True) + npm_shim = bin_directory / "npm" + npm_root = tmp_path / "volta" / "tools" / "image" / "node" / "22.22.1" / "lib" / "node_modules" / "npm" + npm_cli = npm_root / "bin" / "npm-cli.js" + npm_cli.parent.mkdir(parents=True) + npm_cli.write_text("process.exit(0);\n", encoding="utf-8") + (npm_root / "package.json").write_text(json.dumps({"name": "npm"}), encoding="utf-8") + npm_semver = npm_root / "node_modules" / "semver" + npm_semver.mkdir(parents=True) + (npm_semver / "package.json").write_text(json.dumps({"name": "semver"}), encoding="utf-8") + (npm_semver / "index.js").write_text( + "module.exports = {validRange: () => '^20.0.0', valid: value => value, satisfies: () => false};", + encoding="utf-8", + ) + npm_shim.write_text( + f'#!/bin/sh\nexec {shlex.quote(node)} {shlex.quote(str(npm_cli))} "$@"\n', + encoding="utf-8", + ) + npm_shim.chmod(0o755) + global_semver = bin_directory / "node_modules" / "semver" + global_semver.mkdir(parents=True) + (global_semver / "package.json").write_text(json.dumps({"name": "semver"}), encoding="utf-8") + (global_semver / "index.js").write_text( + "module.exports = {validRange: () => '*', valid: value => value, satisfies: () => true};", + encoding="utf-8", + ) + + legacy_evaluation = subprocess.run( + [ + node, + "-e", + ( + 'const {createRequire} = require("module"); ' + 'const semver = createRequire(process.argv[1])("semver"); ' + 'const valid = semver.valid("22.4.1") !== null; ' + 'process.stdout.write(JSON.stringify({matches: [valid && semver.satisfies("22.4.1", "^20.0.0")]}));' + ), + str(npm_shim), + ], + capture_output=True, + text=True, + check=False, + ) + assert legacy_evaluation.returncode == 0 + assert json.loads(legacy_evaluation.stdout) == {"matches": [True]} + + def which(name): + return str(npm_shim) if name == "npm" else None + + monkeypatch.setattr("rsconnect.preflight_node.shutil.which", which) + + result, error = _evaluate_node_range(node, "^20.0.0", ["22.4.1"]) + + assert result is None + assert error == "Could not locate npm's local semver module." + + +@pytest.mark.parametrize( + ("package", "expected_requirement", "expected_status"), + [ + ({"name": "demo"}, None, "ok"), + ({"name": "demo", "engines": {"node": "*"}}, "*", "incompatible"), + ], +) +def test_preflight_distinguishes_absent_range_from_wildcard_for_prereleases( + tmp_path, monkeypatch, package, expected_requirement, expected_status +): + node = shutil.which("node") + if not node or not _npm_cli_path(): + pytest.skip("Node.js and npm's semver module are unavailable") + _, error = _evaluate_node_range(node, None, ["22.0.0-rc.1"]) + if error: + pytest.skip(error) + + project = make_project(tmp_path / "app", package) + monkeypatch.setattr( + "rsconnect.preflight_node.NodeEnvironment", + SimpleNamespace(create=lambda project, node_executable=None: SimpleNamespace(node_version="22.22.1")), + ) + executor, _, _ = make_executor( + new=True, + settings={ + "enabled": True, + "status": {"enabled": True}, + "installations": [{"version": "22.0.0-rc.1", "publishable": True}], + }, + ) + + result = run_node_preflight(executor, str(project), node=node) + + assert result["status"] == expected_status + assert result["node_requires"] == expected_requirement + assert result["server_node_versions"] == ["22.0.0-rc.1"] + assert result["publishable_node_versions"] == ["22.0.0-rc.1"]