Quick start
++ VIP verifies Posit Team across standalone, Kubernetes, and Snowflake + Native App deployments. +
Install VIP, then point it at your server:
uv pip install posit-vip
uv run vip install
diff --git a/README.md b/README.md index 40cc4738..fc5b5ab8 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ An open-source, extensible test suite that validates Posit Team deployments are installed correctly and functioning properly. VIP uses **BDD-style tests** (pytest-bdd + Playwright) to verify Connect, -Workbench, and Package Manager. Results are compiled into an **HTML report** -that can be published to a Connect server. +Workbench, and Package Manager across standalone, Kubernetes, and Snowflake +Native App deployments. Results are compiled into an **HTML report** that can +be published to a Connect server. **Documentation:** https://posit-dev.github.io/vip/ diff --git a/selftests/test_client_auth.py b/selftests/test_client_auth.py new file mode 100644 index 00000000..0418d157 --- /dev/null +++ b/selftests/test_client_auth.py @@ -0,0 +1,99 @@ +"""Tests for vip.client_auth — pluggable HTTP-client auth registry.""" + +from __future__ import annotations + +import httpx +import pytest + +from vip import client_auth +from vip.client_auth import ( + build_client_auth, + get_client_auth_factory, + register_client_auth, +) +from vip.clients.base import BaseClient +from vip.config import VIPConfig + + +@pytest.fixture(autouse=True) +def _restore_registry(): + """Snapshot and restore the module-level registry around each test.""" + saved = dict(client_auth._CLIENT_AUTH_FACTORIES) + try: + yield + finally: + client_auth._CLIENT_AUTH_FACTORIES.clear() + client_auth._CLIENT_AUTH_FACTORIES.update(saved) + + +class _DummyAuth(httpx.Auth): + def auth_flow(self, request): + yield request + + +def _config(idp: str = "") -> VIPConfig: + cfg = VIPConfig() + cfg.auth.idp = idp + return cfg + + +class TestRegistry: + def test_register_and_get_round_trip(self): + def factory(config, product, base_url): + return None + + register_client_auth("snowflake", factory) + assert get_client_auth_factory("snowflake") is factory + + def test_lookup_is_case_insensitive(self): + def factory(config, product, base_url): + return None + + register_client_auth("Snowflake", factory) + assert get_client_auth_factory(" SNOWFLAKE ") is factory + + def test_unregistered_returns_none(self): + assert get_client_auth_factory("nope") is None + + +class TestBuildClientAuth: + def test_no_idp_returns_none(self): + assert build_client_auth(_config(""), "connect", "https://x") is None + + def test_unregistered_idp_returns_none(self): + assert build_client_auth(_config("snowflake"), "connect", "https://x") is None + + def test_invokes_factory_with_product_and_url(self): + calls = [] + auth = _DummyAuth() + + def factory(config, product, base_url): + calls.append((product, base_url)) + return auth + + register_client_auth("snowflake", factory) + result = build_client_auth(_config("snowflake"), "workbench", "https://wb") + assert result is auth + assert calls == [("workbench", "https://wb")] + + def test_factory_may_return_none(self): + register_client_auth("snowflake", lambda c, p, u: None) + assert build_client_auth(_config("snowflake"), "connect", "https://x") is None + + +class TestBaseClientAuthInjection: + def test_auth_is_forwarded_to_httpx_client(self): + auth = _DummyAuth() + client = BaseClient("https://example.com", auth=auth) + try: + assert client._client.auth is auth + finally: + client.close() + + def test_default_has_no_auth(self): + client = BaseClient("https://example.com", auth_header_value="Key abc") + try: + # httpx represents "no auth" as its internal sentinel, not our auth. + assert not isinstance(client._client.auth, _DummyAuth) + finally: + client.close() diff --git a/selftests/test_idp.py b/selftests/test_idp.py index ad945a58..19f7e307 100644 --- a/selftests/test_idp.py +++ b/selftests/test_idp.py @@ -7,7 +7,7 @@ import pytest from vip.auth import AuthConfigError -from vip.idp import SUPPORTED_IDPS, get_idp_strategy +from vip.idp import SUPPORTED_IDPS, _fill_snowflake_login, get_idp_strategy class TestGetIdpStrategy: @@ -19,6 +19,10 @@ def test_okta_returns_callable(self): strategy = get_idp_strategy("okta") assert callable(strategy) + def test_snowflake_returns_callable(self): + strategy = get_idp_strategy("snowflake") + assert callable(strategy) + def test_unknown_idp_raises(self): with pytest.raises(AuthConfigError, match="Unsupported IdP.*unknown.*keycloak.*okta"): get_idp_strategy("unknown") @@ -27,10 +31,78 @@ def test_case_insensitive_lookup(self): assert get_idp_strategy("Keycloak") is get_idp_strategy("keycloak") assert get_idp_strategy("OKTA") is get_idp_strategy("okta") assert get_idp_strategy(" Okta ") is get_idp_strategy("okta") + assert get_idp_strategy("Snowflake") is get_idp_strategy("snowflake") def test_supported_idps_contains_expected(self): assert "keycloak" in SUPPORTED_IDPS assert "okta" in SUPPORTED_IDPS + assert "snowflake" in SUPPORTED_IDPS + + +class TestSnowflakeLogin: + """Behavioural tests for the Snowflake OAuth form-fill strategy. + + The strategy loops, filling one sign-in form per Snowflake OAuth hop + until no form appears. The page mock controls how many hops present a + form via ``username_loc.wait_for`` side effects. + """ + + def _make_page(self, *, num_forms: int, consent_visible: bool): + from playwright.sync_api import TimeoutError as PlaywrightTimeout + + from vip.idp import _SF_PASSWORD, _SF_SUBMIT, _SF_USERNAME + + username_loc = MagicMock(name="username_loc") + # wait_for succeeds for `num_forms` hops, then times out to end the loop. + username_loc.wait_for.side_effect = [None] * num_forms + [PlaywrightTimeout("no form")] + password_loc = MagicMock(name="password_loc") + submit_loc = MagicMock(name="submit_loc") + allow_button = MagicMock(name="allow_button") + if not consent_visible: + allow_button.wait_for.side_effect = PlaywrightTimeout("no consent screen") + + locators = {_SF_USERNAME: username_loc, _SF_PASSWORD: password_loc, _SF_SUBMIT: submit_loc} + page = MagicMock(name="page") + page.locator.side_effect = lambda sel: locators[sel] + page.get_by_role.return_value = allow_button + page.url = "https://acct.snowflakecomputing.com/oauth/authorize" + return page, username_loc, password_loc, submit_loc, allow_button + + def test_fills_credentials_and_submits_second_signin(self): + page, username_loc, password_loc, submit_loc, _ = self._make_page( + num_forms=1, consent_visible=True + ) + _fill_snowflake_login(page, "user@example.com", "s3cret") + + username_loc.fill.assert_called_once_with("user@example.com") + password_loc.fill.assert_called_once_with("s3cret") + # The username/password "Sign in" is the *second* button. + submit_loc.nth.assert_any_call(1) + submit_loc.nth(1).click.assert_called() + + def test_clicks_allow_when_consent_shown(self): + page, _, _, _, allow_button = self._make_page(num_forms=1, consent_visible=True) + _fill_snowflake_login(page, "user", "pass") + allow_button.click.assert_called_once() + + def test_consent_screen_is_optional(self): + page, _, _, _, allow_button = self._make_page(num_forms=1, consent_visible=False) + # Must not raise when the consent screen never appears. + _fill_snowflake_login(page, "user", "pass") + allow_button.click.assert_not_called() + + def test_fills_every_form_in_the_multi_hop_chain(self): + # Two Snowflake hops (product-host ingress, then controller-host) + # must each get the credentials filled — the "double auth". + page, username_loc, _, _, _ = self._make_page(num_forms=2, consent_visible=False) + _fill_snowflake_login(page, "user", "pass") + assert username_loc.fill.call_count == 2 + + def test_stops_when_no_form_appears(self): + # No sign-in form at all (e.g. an already-active session): no fill. + page, username_loc, _, _, _ = self._make_page(num_forms=0, consent_visible=False) + _fill_snowflake_login(page, "user", "pass") + username_loc.fill.assert_not_called() class TestKeycloakUsesTotpGetCode: diff --git a/src/vip/auth.py b/src/vip/auth.py index 104d05d3..0e520a11 100644 --- a/src/vip/auth.py +++ b/src/vip/auth.py @@ -514,7 +514,7 @@ def start_headless_auth( At least one of *connect_url* or *workbench_url* must be provided. The *idp* parameter selects which form automation strategy to use - (e.g. ``"keycloak"``, ``"okta"``). + (e.g. ``"keycloak"``, ``"okta"``, ``"snowflake"``). When *insecure* is ``True``, Playwright ignores TLS certificate errors. When *ca_bundle* is set, the path is exported as ``NODE_EXTRA_CA_CERTS`` @@ -556,12 +556,14 @@ def start_headless_auth( uses_idp = provider.strip().lower() in _IDP_PROVIDERS fill_login = None if uses_idp: + from vip.idp import SUPPORTED_IDPS, get_idp_strategy + if not idp: + supported = ", ".join(f'"{name}"' for name in sorted(SUPPORTED_IDPS)) raise AuthConfigError( f"--headless-auth with provider={provider!r} requires" - ' [auth] idp in vip.toml (supported: "keycloak", "okta")' + f" [auth] idp in vip.toml (supported: {supported})" ) - from vip.idp import get_idp_strategy fill_login = get_idp_strategy(idp) diff --git a/src/vip/cli.py b/src/vip/cli.py index 96d0eada..ab6064d6 100644 --- a/src/vip/cli.py +++ b/src/vip/cli.py @@ -1051,7 +1051,7 @@ def main() -> None: auth_group.add_argument( "--idp", default=None, - help='Identity provider for --headless-auth: "keycloak", "okta". ' + help='Identity provider for --headless-auth: "keycloak", "okta", "snowflake". ' 'Presence implies provider = "oidc" unless overridden in vip.toml.', ) verify_parser.add_argument( diff --git a/src/vip/client_auth.py b/src/vip/client_auth.py new file mode 100644 index 00000000..b1a7e554 --- /dev/null +++ b/src/vip/client_auth.py @@ -0,0 +1,79 @@ +"""Pluggable HTTP-client authentication registry. + +VIP's product API clients (``ConnectClient``, ``WorkbenchClient``, +``PackageManagerClient``) normally authenticate with a static product +credential — a Connect API key, a Package Manager token, etc. Some +deployments front the products with an external authenticator that needs a +different, possibly per-request or per-host, scheme that a single static +``Authorization`` header cannot express. + +The motivating case is a Snowflake Native App, where each product sits behind +a Snowpark Container Services ingress that expects a short-lived +``Authorization: Snowflake Token="..."`` header derived per host via a JWT +token exchange. Rather than teach VIP about Snowflake, this registry lets a +downstream extension register an :class:`httpx.Auth` factory keyed by IdP +name. The product-client fixtures consult the registry and inject the +returned auth into the client. + +Downstream usage (e.g. from an extension ``conftest.py``): + + from vip.client_auth import register_client_auth + + def _snowflake_auth(config, product, base_url): + return MySnowflakeHttpxAuth(...) + + register_client_auth("snowflake", _snowflake_auth) + +The registry is keyed by the configured ``[auth] idp`` value, so the same IdP +name selects both the browser login strategy (:mod:`vip.idp`) and the HTTP +client auth. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from vip.config import VIPConfig + +# A factory receives the loaded config, the product name ("connect", +# "workbench", "package_manager") and that product's base URL, and returns an +# ``httpx.Auth`` to use for the client — or ``None`` to fall back to the +# default static-credential behaviour. +ClientAuthFactory = Callable[["VIPConfig", str, str], "httpx.Auth | None"] + +_CLIENT_AUTH_FACTORIES: dict[str, ClientAuthFactory] = {} + + +def register_client_auth(idp: str, factory: ClientAuthFactory) -> None: + """Register an :class:`httpx.Auth` *factory* for the given *idp* name. + + The *idp* value is normalized (stripped, lowercased). Re-registering an + IdP replaces the previous factory. + """ + _CLIENT_AUTH_FACTORIES[idp.strip().lower()] = factory + + +def get_client_auth_factory(idp: str) -> ClientAuthFactory | None: + """Return the registered factory for *idp*, or ``None`` if unregistered.""" + return _CLIENT_AUTH_FACTORIES.get(idp.strip().lower()) + + +def build_client_auth(config: VIPConfig, product: str, base_url: str) -> httpx.Auth | None: + """Build an :class:`httpx.Auth` for *product* from the registered factory. + + Looks up the factory for the configured ``[auth] idp`` and invokes it. + Returns ``None`` when no IdP is configured, no factory is registered for + it, or the factory itself returns ``None`` — in which case clients use + their default static-credential auth. + """ + idp = (config.auth.idp or "").strip().lower() + if not idp: + return None + factory = _CLIENT_AUTH_FACTORIES.get(idp) + if factory is None: + return None + return factory(config, product, base_url) diff --git a/src/vip/clients/base.py b/src/vip/clients/base.py index 73f533d9..847e32ba 100644 --- a/src/vip/clients/base.py +++ b/src/vip/clients/base.py @@ -21,6 +21,12 @@ class BaseClient: auth_header_value: Full value for the ``Authorization`` header (e.g. ``"Key abc123"`` or ``"Bearer tok"``). Pass an empty string to omit the header. + auth: + Optional ``httpx.Auth`` applied per request. Use for schemes that + cannot be expressed as a single static header — e.g. a token that + must be refreshed or re-derived per target host (Snowflake Native + App SPCS ingress). When set it takes precedence over + *auth_header_value*; typically only one of the two is provided. api_prefix: Path segment appended to *base_url* when constructing the internal httpx client (e.g. ``"/__api__"`` for Connect). The ``base_url`` @@ -34,6 +40,11 @@ class BaseClient: ca_bundle: Path to a custom CA certificate bundle (PEM) to trust in addition to the system roots. Useful for self-signed or corporate CAs. + extra_headers: + Additional static default headers for the httpx client. Use for + app-level auth that must NOT occupy the ``Authorization`` header + because *auth* already owns it — e.g. Connect's ``X-RSC-Authorization`` + when reached through an SPCS ingress that consumes ``Authorization``. """ def __init__( @@ -44,11 +55,15 @@ def __init__( timeout: float = 30.0, insecure: bool = False, ca_bundle: Path | None = None, + auth: httpx.Auth | None = None, + extra_headers: dict[str, str] | None = None, ) -> None: self._base_url = base_url.rstrip("/") headers: dict[str, str] = {} if auth_header_value: headers["Authorization"] = auth_header_value + if extra_headers: + headers.update(extra_headers) # Compute the httpx ``verify`` argument from TLS config: # insecure=True → False (skip all certificate verification) # ca_bundle set → str path (use custom CA bundle) @@ -59,9 +74,10 @@ def __init__( verify = str(ca_bundle) else: verify = True - # Store for subclasses that need to create ad-hoc httpx clients with - # the same TLS configuration (e.g. temporary cookie-based clients). + # Store for subclasses that need to create ad-hoc httpx requests with + # the same TLS configuration and per-request auth (e.g. fetch_content). self._verify = verify + self._auth = auth # HTTPTransport retries cover connection-level failures (e.g. refused # connections, broken pipes). HTTP-level errors (502/503/504) are not # retried here — ConnectClient.wait_for_task already handles those at @@ -77,6 +93,7 @@ def __init__( headers=headers, timeout=timeout, transport=transport, + auth=auth, ) @property @@ -94,6 +111,17 @@ def verify(self) -> bool | str: """ return self._verify + @property + def auth(self) -> httpx.Auth | None: + """The per-request httpx auth for this client, if any. + + Subclasses and tests that issue ad-hoc httpx requests (bypassing the + internal ``_client``) must pass this so the request carries the same + per-host auth — e.g. the Snowflake SPCS ingress token. Without it the + ingress redirects unauthenticated requests to its OAuth login (302). + """ + return self._auth + def close(self) -> None: """Close the underlying httpx client.""" self._client.close() diff --git a/src/vip/clients/connect.py b/src/vip/clients/connect.py index bec789de..9c1767ce 100644 --- a/src/vip/clients/connect.py +++ b/src/vip/clients/connect.py @@ -38,15 +38,24 @@ def __init__( timeout: float = 30.0, insecure: bool = False, ca_bundle: Path | None = None, + auth: httpx.Auth | None = None, ) -> None: api_key = (api_key or "").strip() + # Send the Connect API key via ``X-RSC-Authorization`` rather than the + # standard ``Authorization`` header. Connect honors both, but an SPCS + # ingress (Snowflake Native App) consumes ``Authorization`` for its own + # ``Snowflake Token="..."`` — supplied per request by *auth* — so the two + # auth layers would otherwise collide on a single header. Using the + # alternate header lets ingress auth and the Connect key coexist; it is + # also harmless for non-Snowflake deployments. super().__init__( base_url, - auth_header_value=f"Key {api_key}" if api_key else "", api_prefix="/__api__", timeout=timeout, insecure=insecure, ca_bundle=ca_bundle, + auth=auth, + extra_headers={"X-RSC-Authorization": f"Key {api_key}"} if api_key else None, ) # self._verify is set by BaseClient.__init__ and used by fetch_content. @@ -301,9 +310,16 @@ def fetch_content(self, url: str, *, timeout: float = 30.0) -> httpx.Response: origin = urlparse(self.base_url) origin_key = (origin.scheme, origin.hostname, _normalized_port(origin.scheme, origin.port)) max_redirects = 10 + # Carry the Connect API key via its alternate header (so an SPCS ingress + # can still own ``Authorization``) plus any per-request ingress auth + # (Authorization: Snowflake Token), applied by *self._auth*. The + # same-origin redirect guard below ensures these are never sent off-origin. + rsc = self._client.headers.get("X-RSC-Authorization") + auth_headers = {"X-RSC-Authorization": rsc} if rsc else {} resp = httpx.get( url, - headers={"Authorization": self._client.headers["Authorization"]}, + headers=auth_headers, + auth=self._auth, follow_redirects=False, timeout=timeout, verify=self._verify, @@ -329,7 +345,8 @@ def fetch_content(self, url: str, *, timeout: float = 30.0) -> httpx.Response: break resp = httpx.get( absolute_location, - headers={"Authorization": self._client.headers["Authorization"]}, + headers=auth_headers, + auth=self._auth, follow_redirects=False, timeout=timeout, verify=self._verify, diff --git a/src/vip/clients/packagemanager.py b/src/vip/clients/packagemanager.py index 889318b7..10b967a5 100644 --- a/src/vip/clients/packagemanager.py +++ b/src/vip/clients/packagemanager.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any +import httpx + from vip.clients.base import BaseClient @@ -19,6 +21,7 @@ def __init__( timeout: float = 30.0, insecure: bool = False, ca_bundle: Path | None = None, + auth: httpx.Auth | None = None, ) -> None: super().__init__( base_url, @@ -26,6 +29,7 @@ def __init__( timeout=timeout, insecure=insecure, ca_bundle=ca_bundle, + auth=auth, ) # -- Health / status ---------------------------------------------------- diff --git a/src/vip/clients/workbench.py b/src/vip/clients/workbench.py index 4786be1a..1fd2ec0a 100644 --- a/src/vip/clients/workbench.py +++ b/src/vip/clients/workbench.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any +import httpx + from vip.clients.base import BaseClient @@ -23,6 +25,7 @@ def __init__( timeout: float = 30.0, insecure: bool = False, ca_bundle: Path | None = None, + auth: httpx.Auth | None = None, ) -> None: super().__init__( base_url, @@ -30,6 +33,7 @@ def __init__( timeout=timeout, insecure=insecure, ca_bundle=ca_bundle, + auth=auth, ) # -- Health / info ------------------------------------------------------ diff --git a/src/vip/idp.py b/src/vip/idp.py index 0bab0faf..92e31eb1 100644 --- a/src/vip/idp.py +++ b/src/vip/idp.py @@ -25,6 +25,22 @@ _OKTA_PASSCODE = "input[name='credentials.passcode']" _OKTA_SUBMIT = "input[type='submit'], button[type='submit']" +# Snowflake OAuth selectors. Snowflake's sign-in page renders two +# "Sign in" buttons (a federated-SSO option and the username/password +# option); the username/password submit is the second one. +_SF_USERNAME = '[autocomplete="username"]' +_SF_PASSWORD = '[autocomplete="current-password"]' +_SF_SUBMIT = 'button:has-text("Sign in")' +_SF_ALLOW = "Allow" +# Posit Team products delegate OIDC to the controller, so reaching a product +# bounces through Snowflake OAuth more than once (product-host ingress, then +# controller-host). Fill up to this many sign-in forms per login. +_SF_MAX_FORMS = 3 +# Time to let the OAuth redirect chain reach the next sign-in form (ms). +_SF_NEXT_FORM_TIMEOUT = 20_000 +# Time to wait for an optional "Allow" consent screen (ms). +_SF_CONSENT_TIMEOUT = 5_000 + # Timeout for waiting on form elements (ms). _FORM_TIMEOUT = 15_000 # Timeout for detecting whether MFA is required after login submit (ms). @@ -319,9 +335,69 @@ def _fill_okta_login(page: Page, username: str, password: str) -> None: ) +def _fill_snowflake_login(page: Page, username: str, password: str) -> None: + """Drive Snowflake OAuth for a Posit Team Native App deployment. + + Each product (Connect, Workbench, Package Manager) sits behind a Snowpark + Container Services ingress and delegates OIDC to the deployment's + controller, which is itself behind the ingress. Reaching a product + therefore bounces through Snowflake's OAuth flow (on + ``*.snowflakecomputing.com``) more than once: first for the product + host's ingress, then again for the controller host. Each hop presents the + same Snowflake sign-in form, and the first authorization of an OAuth + client also shows an "Allow" consent. + + This fills every sign-in form in the chain, waiting for each hop's + navigation to settle before looking for the next, until the flow leaves + Snowflake. It does *not* wait for the product page itself to finish + loading — ``_wait_for_product_redirect`` in ``vip.auth`` handles that. + """ + for attempt in range(_SF_MAX_FORMS): + # Give the first form the normal timeout; later hops a longer one, + # since the OAuth chain takes several redirects to reach them. + timeout = _FORM_TIMEOUT if attempt == 0 else _SF_NEXT_FORM_TIMEOUT + try: + page.locator(_SF_USERNAME).wait_for(state="visible", timeout=timeout) + except PlaywrightTimeout: + # No (further) sign-in form — the chain has moved past Snowflake login. + _log_verbose(f">>> Snowflake: no sign-in form after {attempt} fill(s); done.") + break + + before = page.url + page.locator(_SF_USERNAME).fill(username) + page.locator(_SF_PASSWORD).fill(password) + # The username/password "Sign in" button is the second one on the page. + page.locator(_SF_SUBMIT).nth(1).click() + _log_verbose(f">>> Snowflake: submitted sign-in form #{attempt + 1}.") + + # The first authorization of an OAuth client shows an "Allow" consent. + try: + allow_button = page.get_by_role("button", name=_SF_ALLOW) + allow_button.wait_for(state="visible", timeout=_SF_CONSENT_TIMEOUT) + allow_button.click() + _log_verbose(">>> Snowflake: clicked 'Allow' consent.") + except (PlaywrightTimeout, Error): + pass + + # Let the OAuth redirect chain advance to the next hop before looking + # for another form. Filling without waiting for navigation races the + # redirect and re-submits a stale form, which stalls the flow. + # (Named, typed predicate rather than a lambda so mypy can type it; the + # ``_before`` default snapshots this hop's URL — also avoids closing over + # the loop variable.) + def url_advanced(url: str, _before: str = before) -> bool: + return url != _before + + try: + page.wait_for_url(url_advanced, timeout=_SF_NEXT_FORM_TIMEOUT) + except (PlaywrightTimeout, Error): + pass + + _IDP_STRATEGIES: dict[str, Callable[[Page, str, str], None]] = { "keycloak": _fill_keycloak_login, "okta": _fill_okta_login, + "snowflake": _fill_snowflake_login, } SUPPORTED_IDPS = frozenset(_IDP_STRATEGIES.keys()) diff --git a/src/vip_tests/conftest.py b/src/vip_tests/conftest.py index 298435ef..d2161b78 100644 --- a/src/vip_tests/conftest.py +++ b/src/vip_tests/conftest.py @@ -5,6 +5,7 @@ import pytest from pytest_bdd import given +from vip.client_auth import build_client_auth from vip.clients.connect import ConnectClient from vip.clients.packagemanager import PackageManagerClient from vip.clients.workbench import WorkbenchClient @@ -47,12 +48,17 @@ def vip_verbose(request: pytest.FixtureRequest) -> bool: def connect_client(vip_config: VIPConfig) -> ConnectClient | None: if not vip_config.connect.is_configured: return None - require_connect_api_key(vip_config) + # A registered client-auth provider (e.g. Snowflake JWT) authenticates the + # request itself, so a Connect API key is not required in that case. + auth = build_client_auth(vip_config, "connect", vip_config.connect.url) + if auth is None: + require_connect_api_key(vip_config) client = ConnectClient( vip_config.connect.url, api_key=vip_config.connect.api_key, insecure=vip_config.insecure, ca_bundle=vip_config.ca_bundle, + auth=auth, ) yield client client.close() @@ -67,11 +73,13 @@ def connect_url(vip_config: VIPConfig) -> str: def workbench_client(vip_config: VIPConfig) -> WorkbenchClient | None: if not vip_config.workbench.is_configured: return None + auth = build_client_auth(vip_config, "workbench", vip_config.workbench.url) client = WorkbenchClient( vip_config.workbench.url, api_key=vip_config.workbench.api_key, insecure=vip_config.insecure, ca_bundle=vip_config.ca_bundle, + auth=auth, ) yield client client.close() @@ -86,11 +94,13 @@ def workbench_url(vip_config: VIPConfig) -> str: def pm_client(vip_config: VIPConfig) -> PackageManagerClient | None: if not vip_config.package_manager.is_configured: return None + auth = build_client_auth(vip_config, "package_manager", vip_config.package_manager.url) client = PackageManagerClient( vip_config.package_manager.url, token=vip_config.package_manager.token, insecure=vip_config.insecure, ca_bundle=vip_config.ca_bundle, + auth=auth, ) yield client client.close() diff --git a/src/vip_tests/performance/test_concurrency.py b/src/vip_tests/performance/test_concurrency.py index 7a236ef0..59ada3a3 100644 --- a/src/vip_tests/performance/test_concurrency.py +++ b/src/vip_tests/performance/test_concurrency.py @@ -8,6 +8,8 @@ import httpx from pytest_bdd import scenario, then, when +from vip.client_auth import build_client_auth + @scenario("test_concurrency.feature", "Multiple concurrent API requests to Connect succeed") def test_connect_concurrency(): @@ -24,14 +26,16 @@ def test_workbench_concurrency(): pass -def _concurrent_requests(url: str, n: int, verify: bool | str = True) -> list[dict]: +def _concurrent_requests( + url: str, n: int, verify: bool | str = True, auth: httpx.Auth | None = None +) -> list[dict]: """Fire *n* GET requests concurrently and collect results.""" results = [] def _fetch(): start = time.monotonic() try: - resp = httpx.get(url, timeout=30, verify=verify) + resp = httpx.get(url, timeout=30, verify=verify, auth=auth) return {"status": resp.status_code, "elapsed": time.monotonic() - start, "error": None} except Exception as exc: return {"status": None, "elapsed": time.monotonic() - start, "error": str(exc)} @@ -49,7 +53,10 @@ def _fetch(): ) def concurrent_connect(vip_config, performance_config): url = f"{vip_config.connect.url}/__api__/server_settings" - return _concurrent_requests(url, performance_config.concurrent_requests, vip_config.verify) + auth = build_client_auth(vip_config, "connect", vip_config.connect.url) + return _concurrent_requests( + url, performance_config.concurrent_requests, vip_config.verify, auth + ) @when( @@ -58,7 +65,10 @@ def concurrent_connect(vip_config, performance_config): ) def concurrent_pm(vip_config, performance_config): url = f"{vip_config.package_manager.url}/__api__/status" - return _concurrent_requests(url, performance_config.concurrent_requests, vip_config.verify) + auth = build_client_auth(vip_config, "package_manager", vip_config.package_manager.url) + return _concurrent_requests( + url, performance_config.concurrent_requests, vip_config.verify, auth + ) @when( @@ -67,7 +77,10 @@ def concurrent_pm(vip_config, performance_config): ) def concurrent_workbench(vip_config, performance_config): url = f"{vip_config.workbench.url}/health-check" - return _concurrent_requests(url, performance_config.concurrent_requests, vip_config.verify) + auth = build_client_auth(vip_config, "workbench", vip_config.workbench.url) + return _concurrent_requests( + url, performance_config.concurrent_requests, vip_config.verify, auth + ) @then("all requests succeed") diff --git a/src/vip_tests/performance/test_login_load_times.py b/src/vip_tests/performance/test_login_load_times.py index 622a4bb8..ab59e92a 100644 --- a/src/vip_tests/performance/test_login_load_times.py +++ b/src/vip_tests/performance/test_login_load_times.py @@ -8,6 +8,8 @@ import pytest from pytest_bdd import parsers, scenarios, then, when +from vip.client_auth import build_client_auth + scenarios("test_login_load_times.feature") @@ -30,6 +32,7 @@ def measure_load_time(product, vip_config, performance_config): pytest.skip(f"{product} is not configured") path = _LOGIN_PATHS[product] url = f"{pc.url}{path}" + auth = build_client_auth(vip_config, product_key, pc.url) try: start = time.monotonic() resp = httpx.get( @@ -37,6 +40,7 @@ def measure_load_time(product, vip_config, performance_config): follow_redirects=True, timeout=performance_config.page_load_timeout * 3, verify=vip_config.verify, + auth=auth, ) elapsed = time.monotonic() - start except (httpx.ConnectError, httpx.ProxyError, httpx.ConnectTimeout) as exc: diff --git a/src/vip_tests/performance/test_package_install_speed.py b/src/vip_tests/performance/test_package_install_speed.py index 679cc73d..a8a51dfa 100644 --- a/src/vip_tests/performance/test_package_install_speed.py +++ b/src/vip_tests/performance/test_package_install_speed.py @@ -44,7 +44,12 @@ def download_cran(pm_client, cran_repo, performance_config): # Download the PACKAGES index as a proxy for package download speed. url = f"{pm_client.base_url}/{cran_repo['name']}/latest/src/contrib/PACKAGES" start = time.monotonic() - resp = httpx.get(url, timeout=performance_config.download_timeout, verify=pm_client.verify) + resp = httpx.get( + url, + timeout=performance_config.download_timeout, + verify=pm_client.verify, + auth=pm_client.auth, + ) elapsed = time.monotonic() - start resp.raise_for_status() return elapsed @@ -54,7 +59,12 @@ def download_cran(pm_client, cran_repo, performance_config): def download_pypi(pm_client, pypi_repo, performance_config): url = f"{pm_client.base_url}/{pypi_repo['name']}/latest/simple/pip/" start = time.monotonic() - resp = httpx.get(url, timeout=performance_config.download_timeout, verify=pm_client.verify) + resp = httpx.get( + url, + timeout=performance_config.download_timeout, + verify=pm_client.verify, + auth=pm_client.auth, + ) elapsed = time.monotonic() - start resp.raise_for_status() return elapsed diff --git a/src/vip_tests/performance/test_resource_usage.py b/src/vip_tests/performance/test_resource_usage.py index 4162ce01..45da2531 100644 --- a/src/vip_tests/performance/test_resource_usage.py +++ b/src/vip_tests/performance/test_resource_usage.py @@ -11,6 +11,8 @@ import pytest from pytest_bdd import given, scenario, then, when +from vip.client_auth import build_client_auth + @scenario( "test_resource_usage.feature", "Products respond within acceptable time under moderate load" @@ -38,16 +40,20 @@ def product_configured(vip_config): @when("I generate moderate API traffic for 10 seconds", target_fixture="load_test_results") def generate_traffic_and_measure_response_times(vip_config, vip_verbose): """Generate concurrent API traffic and collect response times.""" - # Collect health check URLs from configured products - urls = [] + # Collect (health-check URL, ingress auth) pairs from configured products. + # The auth is per-product so each request carries the SPCS ingress token. + targets: list[tuple[str, httpx.Auth | None]] = [] if vip_config.connect.is_configured: - urls.append(f"{vip_config.connect.url}/__api__/server_settings") + auth = build_client_auth(vip_config, "connect", vip_config.connect.url) + targets.append((f"{vip_config.connect.url}/__api__/server_settings", auth)) if vip_config.workbench.is_configured: - urls.append(f"{vip_config.workbench.url}/health-check") + auth = build_client_auth(vip_config, "workbench", vip_config.workbench.url) + targets.append((f"{vip_config.workbench.url}/health-check", auth)) if vip_config.package_manager.is_configured: - urls.append(f"{vip_config.package_manager.url}/__api__/status") + auth = build_client_auth(vip_config, "package_manager", vip_config.package_manager.url) + targets.append((f"{vip_config.package_manager.url}/__api__/status", auth)) - if not urls: + if not targets: pytest.skip("No product URLs available for load testing") stop_at = time.monotonic() + 10 @@ -56,12 +62,12 @@ def generate_traffic_and_measure_response_times(vip_config, vip_verbose): verify = vip_config.verify - def _fetch_loop(url: str): + def _fetch_loop(url: str, auth: httpx.Auth | None): """Continuously fetch URL until stop_at, collecting timing and status.""" while time.monotonic() < stop_at: start = time.monotonic() try: - resp = httpx.get(url, timeout=10, verify=verify) + resp = httpx.get(url, timeout=10, verify=verify, auth=auth) elapsed = time.monotonic() - start results.append({"elapsed": elapsed, "status": resp.status_code, "error": None}) if verbose: @@ -82,8 +88,8 @@ def _fetch_loop(url: str): time.sleep(0.1) with ThreadPoolExecutor(max_workers=4) as pool: - for url in urls: - pool.submit(_fetch_loop, url) + for url, auth in targets: + pool.submit(_fetch_loop, url, auth) return results @@ -131,19 +137,23 @@ def check_prometheus_endpoints(vip_config): products_to_check = [] if vip_config.connect.is_configured: - products_to_check.append(("Connect", f"{vip_config.connect.url}/metrics")) + products_to_check.append(("Connect", "connect", vip_config.connect.url)) if vip_config.workbench.is_configured: - products_to_check.append(("Workbench", f"{vip_config.workbench.url}/metrics")) + products_to_check.append(("Workbench", "workbench", vip_config.workbench.url)) if vip_config.package_manager.is_configured: - products_to_check.append(("Package Manager", f"{vip_config.package_manager.url}/metrics")) + products_to_check.append( + ("Package Manager", "package_manager", vip_config.package_manager.url) + ) if not products_to_check: pytest.skip("No products configured for Prometheus check") failures = [] - for product_name, metrics_url in products_to_check: + for product_name, product_key, base_url in products_to_check: + metrics_url = f"{base_url}/metrics" + auth = build_client_auth(vip_config, product_key, base_url) try: - resp = httpx.get(metrics_url, timeout=10, verify=vip_config.verify) + resp = httpx.get(metrics_url, timeout=10, verify=vip_config.verify, auth=auth) if resp.status_code != 200: failures.append( f"{product_name}: /metrics returned {resp.status_code} (expected 200)" diff --git a/vip.toml.example b/vip.toml.example index 926f7a8f..7d06c2cb 100644 --- a/vip.toml.example +++ b/vip.toml.example @@ -74,8 +74,10 @@ url = "https://packagemanager.example.com" # Authentication provider in use: "password", "ldap", "saml", "oidc", "oauth2" provider = "password" -# Identity provider for --headless-auth: "keycloak", "okta" +# Identity provider for --headless-auth: "keycloak", "okta", "snowflake" # Only required when provider is "oidc", "saml", or "oauth2". +# For Snowflake Native App deployments, use provider = "oauth2" and +# idp = "snowflake". # idp = "keycloak" # Test user credentials. diff --git a/website/src/pages/feature-matrix.astro b/website/src/pages/feature-matrix.astro index b239c170..c977677b 100644 --- a/website/src/pages/feature-matrix.astro +++ b/website/src/pages/feature-matrix.astro @@ -157,6 +157,28 @@ const productTotalSum = Object.values(productTotals).reduce((a, b) => a + b, 0); + {/* Deployment environments section */} +
+ Posit Team deployment targets that VIP can verify. +
++ VIP verifies Posit Team across standalone, Kubernetes, and Snowflake + Native App deployments. +
Install VIP, then point it at your server:
uv pip install posit-vip
uv run vip install