diff --git a/.github/workflows/_system_test.yml b/.github/workflows/_system_test.yml index 2d9bfea5b..661d89d21 100644 --- a/.github/workflows/_system_test.yml +++ b/.github/workflows/_system_test.yml @@ -31,6 +31,9 @@ jobs: EPICS_CA_NAME_SERVERS: 127.0.0.1:9064 EPICS_PVA_NAME_SERVERS: 127.0.0.1:9075 run: uv run blueapi -c ${{ github.workspace }}/tests/system_tests/config.yaml serve & + + - name: Install playwright browsers + run: uv run playwright install --with-deps - name: Run tests run: uv run --locked tox -e system-test diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..ea6453689 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,2 @@ +tests/system_tests/services/blueapi-oauth2-proxy/oauth2-proxy.cfg:generic-api-key:16 +tests/system_tests/services/tiled-oauth2-proxy/oauth2-proxy.cfg:generic-api-key:14 diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index d54bc5357..dd6240943 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -486,17 +486,22 @@ "title": "Url", "type": "string" }, - "api_key": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Api Key" + "token_exchange_secret": { + "default": "", + "description": "Token exchange client secret", + "title": "Token Exchange Secret", + "type": "string" + }, + "token_url": { + "default": "", + "title": "Token Url", + "type": "string" + }, + "token_exchange_client_id": { + "default": "", + "description": "Token exchange Client ID", + "title": "Token Exchange Client Id", + "type": "string" } }, "title": "TiledConfig", diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 1578c0dad..bba402ac6 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -893,23 +893,29 @@ "title": "TiledConfig", "type": "object", "properties": { - "api_key": { - "title": "Api Key", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "enabled": { "title": "Enabled", "description": "True if blueapi should forward data to a Tiled instance", "default": false, "type": "boolean" }, + "token_exchange_client_id": { + "title": "Token Exchange Client Id", + "description": "Token exchange Client ID", + "default": "", + "type": "string" + }, + "token_exchange_secret": { + "title": "Token Exchange Secret", + "description": "Token exchange client secret", + "default": "", + "type": "string" + }, + "token_url": { + "title": "Token Url", + "default": "", + "type": "string" + }, "url": { "title": "Url", "default": "http://localhost:8407/", diff --git a/pyproject.toml b/pyproject.toml index ea922c7c7..5da9bf8e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ ] description = "Lightweight bluesky-as-a-service wrapper application. Also usable as a library." dependencies = [ - "tiled[client]>=0.2.3", + "tiled[client] @ git+https://github.com/bluesky/tiled.git@add-auth-for-client", "bluesky[plotting]>=1.14.0", # plotting includes matplotlib, required for BestEffortCallback in run plans "ophyd-async>=0.13.5", "aioca", @@ -69,7 +69,10 @@ dev = [ "mock", "jwcrypto", "deepdiff", - "tiled[minimal-server]>=0.2.3", # For system-test of dls.py + "tiled[minimal-server] @ git+https://github.com/bluesky/tiled.git@add-auth-for-client", # For system-test of dls.py + "playwright", + "pytest-playwright", + "respx", ] [project.scripts] diff --git a/src/blueapi/config.py b/src/blueapi/config.py index eb1b3122e..e16b9e3de 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -112,7 +112,13 @@ class TiledConfig(BlueapiBaseModel): default=False, ) url: HttpUrl = HttpUrl("http://localhost:8407") - api_key: str | None = os.environ.get("TILED_SINGLE_USER_API_KEY", None) + token_exchange_secret: str = Field( + description="Token exchange client secret", default="" + ) + token_url: str = Field(default="") + token_exchange_client_id: str = Field( + description="Token exchange Client ID", default="" + ) class WorkerEventConfig(BlueapiBaseModel): diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index c22a96d86..d3a40d31f 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -181,6 +181,17 @@ def _update_scan_num(md: dict[str, Any]) -> int: "Tiled has been configured but `instrument` metadata is not set - " "this field is required to make authorization decisions." ) + if configuration.oidc is None: + raise InvalidConfigError( + "Tiled has been configured but oidc configuration is missing " + "this field is required to make authorization decisions." + ) + if tiled_conf.token_exchange_secret == "": + raise InvalidConfigError( + "Tiled has been enabled but Token exchange secret has not been set " + "this field is required to enable tiled insertion." + ) + tiled_conf.token_url = configuration.oidc.token_endpoint self.tiled_conf = tiled_conf def find_device(self, addr: str | list[str]) -> Device | None: diff --git a/src/blueapi/service/authentication.py b/src/blueapi/service/authentication.py index 77bb0f6c0..67ffd8629 100644 --- a/src/blueapi/service/authentication.py +++ b/src/blueapi/service/authentication.py @@ -2,24 +2,27 @@ import base64 import os +import threading import time import webbrowser from abc import ABC, abstractmethod +from enum import Enum from functools import cached_property from http import HTTPStatus from pathlib import Path from typing import Any, cast +import httpx import jwt import requests -from pydantic import TypeAdapter +from pydantic import BaseModel, TypeAdapter, computed_field from requests.auth import AuthBase -from blueapi.config import OIDCConfig +from blueapi.config import OIDCConfig, TiledConfig from blueapi.service.model import Cache DEFAULT_CACHE_DIR = "~/.cache/" -SCOPES = "openid offline_access" +SCOPES = "openid" class CacheManager(ABC): @@ -239,3 +242,104 @@ def __call__(self, request): if self.token: request.headers["Authorization"] = f"Bearer {self.token}" return request + + +class TokenType(str, Enum): + refresh_token = "refresh_token" + access_token = "access_token" + + +class Token(BaseModel): + token: str + expires_at: float | None + + @computed_field + @property + def expired(self) -> bool: + if self.expires_at is None: + # Assume token is valid + return False + return time.time() > self.expires_at + + def _get_token_expires_at( + self, token_dict: dict[str, Any], token_type: TokenType + ) -> int | None: + expires_at = None + if token_type == TokenType.access_token: + if "expires_in" in token_dict: + expires_at = int(time.time()) + int(token_dict["expires_in"]) + elif token_type == TokenType.refresh_token: + if "refresh_expires_in" in token_dict: + expires_at = int(time.time()) + int(token_dict["refresh_expires_in"]) + return expires_at + + def __init__(self, token_dict: dict[str, Any], token_type: TokenType): + token = token_dict.get(token_type) + if token is None: + raise ValueError(f"Not able to find {token_type} in response") + super().__init__( + token=token, expires_at=self._get_token_expires_at(token_dict, token_type) + ) + + def __str__(self) -> str: + return str(self.token) + + +class TiledAuth(httpx.Auth): + def __init__(self, tiled_config: TiledConfig, blueapi_jwt_token: str): + self._tiled_config = tiled_config + self._blueapi_jwt_token = blueapi_jwt_token + self._sync_lock = threading.RLock() + self._access_token: Token | None = None + self._refresh_token: Token | None = None + + def exchange_access_token(self): + request_data = { + "client_id": self._tiled_config.token_exchange_client_id, + "client_secret": self._tiled_config.token_exchange_secret, + "subject_token": self._blueapi_jwt_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token", + } + with self._sync_lock: + response = httpx.post( + self._tiled_config.token_url, + data=request_data, + ) + response.raise_for_status() + self.sync_tokens(response.json()) + + def refresh_token(self): + if self._refresh_token is None: + raise Exception("Cannot refresh session as no refresh token available") + with self._sync_lock: + response = httpx.post( + self._tiled_config.token_url, + data={ + "client_id": self._tiled_config.token_exchange_client_id, + "client_secret": self._tiled_config.token_exchange_secret, + "grant_type": "refresh_token", + "refresh_token": self._refresh_token, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + self.sync_tokens(response.json()) + + def sync_tokens(self, response): + self._access_token = Token(response, TokenType.access_token) + self._refresh_token = Token(response, TokenType.refresh_token) + + def sync_auth_flow(self, request): + if self._access_token is not None and self._access_token.expired is not True: + request.headers["Authorization"] = f"Bearer {self._access_token}" + yield request + elif self._access_token is None: + self.exchange_access_token() + request.headers["Authorization"] = f"Bearer {self._access_token}" + yield request + else: + self.refresh_token() + request.headers["Authorization"] = f"Bearer {self._access_token}" + yield request diff --git a/src/blueapi/service/constants.py b/src/blueapi/service/constants.py new file mode 100644 index 000000000..d5171dac5 --- /dev/null +++ b/src/blueapi/service/constants.py @@ -0,0 +1,4 @@ +CONTEXT_HEADER = "traceparent" +VENDOR_CONTEXT_HEADER = "tracestate" +AUTHORIZAITON_HEADER = "authorization" +PROPAGATED_HEADERS = {CONTEXT_HEADER, VENDOR_CONTEXT_HEADER, AUTHORIZAITON_HEADER} diff --git a/src/blueapi/service/interface.py b/src/blueapi/service/interface.py index 9bc8bcef8..08a40c95c 100644 --- a/src/blueapi/service/interface.py +++ b/src/blueapi/service/interface.py @@ -12,6 +12,8 @@ from blueapi.core.context import BlueskyContext from blueapi.core.event import EventStream from blueapi.log import set_up_logging +from blueapi.service.authentication import TiledAuth +from blueapi.service.constants import AUTHORIZAITON_HEADER from blueapi.service.model import ( DeviceModel, PlanModel, @@ -184,10 +186,27 @@ def begin_task( if tiled_config := active_context.tiled_conf: # Tiled queries the root node, so must create an authorized client + blueapi_jwt_token = "" + if pass_through_headers is None: + raise ValueError( + "Tiled config is enabled but no " + f"{AUTHORIZAITON_HEADER} header in request" + ) + authorization_header_value = pass_through_headers.get(AUTHORIZAITON_HEADER) + from fastapi.security.utils import get_authorization_scheme_param + + _, blueapi_jwt_token = get_authorization_scheme_param( + authorization_header_value + ) + + if blueapi_jwt_token == "": + raise KeyError("Tiled config is enabled but no Bearer Token in request") tiled_client = from_uri( str(tiled_config.url), - api_key=tiled_config.api_key, - headers=pass_through_headers, + auth=TiledAuth( + tiled_config, + blueapi_jwt_token=blueapi_jwt_token, + ), ) tiled_writer_token = active_context.run_engine.subscribe( TiledWriter(tiled_client, batch_size=1) diff --git a/src/blueapi/service/main.py b/src/blueapi/service/main.py index 9711f2005..4963e26ce 100644 --- a/src/blueapi/service/main.py +++ b/src/blueapi/service/main.py @@ -36,6 +36,11 @@ from blueapi.config import ApplicationConfig, OIDCConfig from blueapi.service import interface +from blueapi.service.constants import ( + CONTEXT_HEADER, + PROPAGATED_HEADERS, + VENDOR_CONTEXT_HEADER, +) from blueapi.worker import TrackableTask, WorkerState from blueapi.worker.event import TaskStatusEnum @@ -67,10 +72,7 @@ RUNNER: WorkerDispatcher | None = None LOGGER = logging.getLogger(__name__) -CONTEXT_HEADER = "traceparent" -VENDOR_CONTEXT_HEADER = "tracestate" -AUTHORIZAITON_HEADER = "authorization" -PROPAGATED_HEADERS = {CONTEXT_HEADER, VENDOR_CONTEXT_HEADER, AUTHORIZAITON_HEADER} + DOCS_ENDPOINT = "/docs" diff --git a/tests/system_tests/compose.yaml b/tests/system_tests/compose.yaml index 9c2efce8b..621682fe6 100644 --- a/tests/system_tests/compose.yaml +++ b/tests/system_tests/compose.yaml @@ -6,7 +6,7 @@ services: image: ghcr.io/diamondlightsource/numtracker:1.0.2 ports: - "8406:8000" - + rabbitmq: image: docker.io/rabbitmq:4.0-management ports: @@ -39,17 +39,22 @@ services: retries: 10 start_period: 30s - tiled: + tiled: image: ghcr.io/bluesky/tiled:0.2.3 network_mode: host environment: - PYTHONPATH=/deploy/ - volumes: + volumes: - ./services/tiled_config:/deploy/config - command: ["tiled", "serve", "config", "--host", "0.0.0.0", "--port", "8407"] - depends_on: - keycloak: + command: ["tiled", "serve", "config", "--host", "0.0.0.0", "--port", "8407"] + depends_on: + keycloak: condition: service_healthy + develop: + watch: + - action: restart + path: ./services/tiled_config + target: /deploy/config opa: image: openpolicyagent/opa:edge-static-debug @@ -59,3 +64,27 @@ services: environment: - ISSUER=http://localhost:8081/realms/master entrypoint: "sh /mnt/entrypoint.sh" + + blueapi-oauth2-proxy: + network_mode: host + image: "quay.io/oauth2-proxy/oauth2-proxy:v7.13.0" + volumes: + - ./services/blueapi-oauth2-proxy/:/opt/config + command: ["--alpha-config=/opt/config/oauth2-alpha.yaml","--config=/opt/config/oauth2-proxy.cfg"] + # ports: + # - 4180:4180 + depends_on: + keycloak: + condition: service_healthy + + tiled-oauth2-proxy: + network_mode: host + image: "quay.io/oauth2-proxy/oauth2-proxy:v7.13.0" + volumes: + - ./services/tiled-oauth2-proxy/:/opt/config + command: ["--alpha-config=/opt/config/oauth2-alpha.yaml","--config=/opt/config/oauth2-proxy.cfg"] + # ports: + # - 4181:4181 + depends_on: + keycloak: + condition: service_healthy diff --git a/tests/system_tests/config-cli-without-stomp.yaml b/tests/system_tests/config-cli-without-stomp.yaml new file mode 100644 index 000000000..9e54fee04 --- /dev/null +++ b/tests/system_tests/config-cli-without-stomp.yaml @@ -0,0 +1,5 @@ +api: + url: http://localhost:8000 +logging: + level: ERROR +auth_token_path: /tmp/blueapi-system-test-cache diff --git a/tests/system_tests/config-cli.yaml b/tests/system_tests/config-cli.yaml new file mode 100644 index 000000000..c2958733d --- /dev/null +++ b/tests/system_tests/config-cli.yaml @@ -0,0 +1,11 @@ +stomp: + enabled: true + auth: + username: guest + password: guest + url: tcp://localhost:61613 +api: + url: http://localhost:8000 +logging: + level: ERROR +auth_token_path: /tmp/blueapi-system-test-cache diff --git a/tests/system_tests/config.yaml b/tests/system_tests/config.yaml index 11dcf5f05..71ec187ac 100644 --- a/tests/system_tests/config.yaml +++ b/tests/system_tests/config.yaml @@ -18,6 +18,8 @@ numtracker: tiled: enabled: true url: http://localhost:8407/api/v1 + token_exchange_client_id: "ixx-blueapi" + token_exchange_secret: "blueapi-secret" oidc: well_known_url: "http://localhost:8081/realms/master/.well-known/openid-configuration" client_id: "ixx-cli-blueapi" diff --git a/tests/system_tests/services/blueapi-oauth2-proxy/oauth2-alpha.yaml b/tests/system_tests/services/blueapi-oauth2-proxy/oauth2-alpha.yaml new file mode 100644 index 000000000..1502d004e --- /dev/null +++ b/tests/system_tests/services/blueapi-oauth2-proxy/oauth2-alpha.yaml @@ -0,0 +1,27 @@ +injectRequestHeaders: +- name: Authorization + values: + - claim: access_token + prefix: "Bearer " +providers: +- provider: oidc + clientID: ixx-blueapi + clientSecret: blueapi-secret + id: authn + oidcConfig: + audienceClaims: ["aud"] + extraAudiences: ["account"] + emailClaim: sub + insecureAllowUnverifiedEmail: true + insecureSkipNonce: true + issuerURL: http://localhost:8081/realms/master +server: + BindAddress: localhost:4180 + SecureBindAddress: "" + TLS: null +upstreamConfig: + proxyRawPath: true + upstreams: + - id: ixx-blueapi + path: / + uri: http://localhost:8000 diff --git a/tests/system_tests/services/blueapi-oauth2-proxy/oauth2-proxy.cfg b/tests/system_tests/services/blueapi-oauth2-proxy/oauth2-proxy.cfg new file mode 100644 index 000000000..809aba535 --- /dev/null +++ b/tests/system_tests/services/blueapi-oauth2-proxy/oauth2-proxy.cfg @@ -0,0 +1,20 @@ +## OAuth2 Proxy Config File +## https://github.com/oauth2-proxy/oauth2-proxy + +email_domains = [ + "*" +] + +skip_auth_routes=[ + "GET=^/config/oidc", + "GET=^/healthz" +] + +skip_jwt_bearer_tokens = true +skip_provider_button = true + +cookie_secret = "Dhf3pGspLQ5DjtzIz_la8mq2MkCsXzeV" +cookie_expire="30m" +cookie_refresh="1m" +cookie_secure = false +whitelist_domains = "localhost:8081" diff --git a/tests/system_tests/services/keycloak_config/startup.sh b/tests/system_tests/services/keycloak_config/startup.sh index aca5261cb..6537daaf3 100644 --- a/tests/system_tests/services/keycloak_config/startup.sh +++ b/tests/system_tests/services/keycloak_config/startup.sh @@ -44,7 +44,7 @@ protocolMappers='{ }' -for client in "system-test-blueapi" "ixx-cli-blueapi"; do +for client in "system-test-blueapi" "ixx-cli-blueapi" "ixx-blueapi" "tiled" "tiled-cli"; do if ! kcreg.sh get "$client" >/dev/null 2>&1; then tmpfile=$(mktemp) echo $protocolMappers > $tmpfile @@ -56,16 +56,93 @@ for client in "system-test-blueapi" "ixx-cli-blueapi"; do -s standardFlowEnabled=false \ -s serviceAccountsEnabled=true \ -s 'redirectUris=["/*"]' \ - -s attributes='{"access.token.lifespan":"86400"}' \ -f $tmpfile ;; "ixx-cli-blueapi") + protocolMappers='{ + "protocolMappers": [ + { + "name": "fedid", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "config": { + "introspection.token.claim": "true", + "claim.value": "alice", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "fedid", + "jsonType.label": "String" + } + }, + { + "name": "blueapi", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true", + "included.custom.audience": "blueapi" + } + }, + { + "name": "ixx-blueapi", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true", + "included.custom.audience": "ixx-blueapi" + } + } + ] + }' + echo $protocolMappers > $tmpfile + kcreg.sh create -x \ + -s clientId=$client \ + -s standardFlowEnabled=false \ + -s publicClient=true \ + -s 'redirectUris=["/*"]' \ + -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' \ + -f $tmpfile + ;; + "ixx-blueapi") + kcreg.sh create -x \ + -s clientId=$client \ + -s standardFlowEnabled=true \ + -s publicClient=false \ + -s secret="blueapi-secret" \ + -s rootUrl="http://localhost:4180" \ + -s adminUrl="http://localhost:4180" \ + -s baseUrl="http://localhost:4180" \ + -s 'redirectUris=["http://localhost:4180/*"]' \ + -s 'webOrigins=["http://localhost:4180/*"]' \ + -s 'attributes={"frontchannel.logout.session.required":"true","use.refresh.tokens":"true","standard.token.exchange.enabled": "true","standard.token.exchange.enableRefreshRequestedTokenType": "SAME_SESSION"}' \ + -f $tmpfile + ;; + "tiled") + sed -i 's/blueapi/tiled/g' $tmpfile + kcreg.sh create -x \ + -s clientId=$client \ + -s standardFlowEnabled=true \ + -s publicClient=false \ + -s secret="tiled-secret" \ + -s rootUrl="http://localhost:4181" \ + -s adminUrl="http://localhost:4181" \ + -s baseUrl="http://localhost:4181" \ + -s 'redirectUris=["http://localhost:4181/*"]' \ + -s 'webOrigins=["http://localhost:4181/*"]' \ + -s 'attributes={"frontchannel.logout.session.required":"true","use.refresh.tokens":"true"}' \ + -f $tmpfile + ;; + "tiled-cli") + sed -i 's/blueapi/tiled/g' $tmpfile kcreg.sh create -x \ -s clientId=$client \ -s standardFlowEnabled=false \ -s publicClient=true \ -s 'redirectUris=["/*"]' \ - -s 'attributes={"access.token.lifespan":"86400","frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' \ + -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' \ -f $tmpfile ;; esac diff --git a/tests/system_tests/services/tiled-oauth2-proxy/oauth2-alpha.yaml b/tests/system_tests/services/tiled-oauth2-proxy/oauth2-alpha.yaml new file mode 100644 index 000000000..0d6b959d2 --- /dev/null +++ b/tests/system_tests/services/tiled-oauth2-proxy/oauth2-alpha.yaml @@ -0,0 +1,27 @@ +injectRequestHeaders: +- name: Authorization + values: + - claim: access_token + prefix: "Bearer " +providers: +- provider: oidc + clientID: tiled + clientSecret: tiled-secret + id: authn + oidcConfig: + audienceClaims: ["aud"] + extraAudiences: ["account"] + emailClaim: sub + insecureAllowUnverifiedEmail: true + insecureSkipNonce: true + issuerURL: http://localhost:8081/realms/master +server: + BindAddress: localhost:4181 + SecureBindAddress: "" + TLS: null +upstreamConfig: + proxyRawPath: true + upstreams: + - id: tiled + path: / + uri: http://localhost:8407 diff --git a/tests/system_tests/services/tiled-oauth2-proxy/oauth2-proxy.cfg b/tests/system_tests/services/tiled-oauth2-proxy/oauth2-proxy.cfg new file mode 100644 index 000000000..3bfc12e62 --- /dev/null +++ b/tests/system_tests/services/tiled-oauth2-proxy/oauth2-proxy.cfg @@ -0,0 +1,18 @@ +## OAuth2 Proxy Config File +## https://github.com/oauth2-proxy/oauth2-proxy + +email_domains = [ + "*" +] + +skip_auth_routes=[ +"GET=^/api/v1", +] + +skip_jwt_bearer_tokens = true +skip_provider_button = true +cookie_secret = "isNsE3dWf1jum4BrqaU7PvXmwCjaqNQJ4sfAWTfuhLY=" +cookie_expire="30m" +cookie_refresh="1m" +cookie_secure = false +whitelist_domains = "localhost:8081" diff --git a/tests/system_tests/services/tiled_config/config.yml b/tests/system_tests/services/tiled_config/config.yml index 94f82d520..ddd427a8a 100644 --- a/tests/system_tests/services/tiled_config/config.yml +++ b/tests/system_tests/services/tiled_config/config.yml @@ -1,9 +1,4 @@ -database: - uri: sqlite:////storage/auth.db - init_if_not_exists: true authentication: - # Any HTTP client that can connect can read, an API key is still required to write. - allow_anonymous_access: true providers: - provider: keycloak_oidc authenticator: tiled.authenticators:ProxiedOIDCAuthenticator @@ -12,7 +7,7 @@ authentication: client_id: tiled device_flow_client_id: tiled-cli well_known_uri: "http://localhost:8081/realms/master/.well-known/openid-configuration" - confirmation_message: "You have logged in with authn.diamond.ac.uk as {id}." + confirmation_message: "You have logged in with Proxied OIDC as {id}." trees: - path: / tree: catalog diff --git a/tests/system_tests/test_blueapi_system.py b/tests/system_tests/test_blueapi_system.py index 0271331a6..25d8ef696 100644 --- a/tests/system_tests/test_blueapi_system.py +++ b/tests/system_tests/test_blueapi_system.py @@ -1,13 +1,13 @@ import inspect +import re +import subprocess import time from asyncio import Queue -from collections.abc import Generator from pathlib import Path -from unittest.mock import MagicMock, patch import pytest import requests -from bluesky_stomp.models import BasicAuthentication +from playwright.sync_api import sync_playwright from pydantic import TypeAdapter from requests.exceptions import ConnectionError from scanspec.specs import Line @@ -22,9 +22,9 @@ ApplicationConfig, ConfigLoader, OIDCConfig, - StompConfig, ) from blueapi.core.bluesky_types import DataEvent +from blueapi.service.authentication import SessionCacheManager from blueapi.service.model import ( DeviceResponse, PlanResponse, @@ -66,10 +66,13 @@ # 2. Spin up blueapi server (inside devcontainer) # # source tests/system_tests/.env -# export TILED_SINGLE_USER_API_KEY=foo # blueapi -c tests/system_tests/config.yaml serve # -# Note: You can login into blueapi using username: admin and password: admin +# Note: You can login into blueapi and tiled using username: admin and password: admin +# blueapi -c tests/system_tests/config-cli.yaml login +# Blueapi is hosted at http://localhost:4180 and +# Tiled is hosted at http://localhost:4181 +# # 3. Run the system tests # tox -e system-test # @@ -85,13 +88,21 @@ OIDC_TOKEN_ENDPOINT = KEYCLOAK_BASE_URL + "realms/master/protocol/openid-connect/token" -@pytest.fixture -def client_without_auth() -> Generator[BlueapiClient]: - with patch( - "blueapi.service.authentication.SessionManager.from_cache", - return_value=None, - ): - yield BlueapiClient.from_config(config=ApplicationConfig()) +@pytest.fixture(scope="module") +def client_without_auth() -> BlueapiClient: + return BlueapiClient.from_config(config=ApplicationConfig()) + + +@pytest.fixture(scope="module", autouse=True) +def wait_for_server(client_without_auth: BlueapiClient): + for _ in range(20): + try: + client_without_auth.get_oidc_config() + return + except ConnectionError: + ... + time.sleep(0.5) + raise TimeoutError("No connection to the blueapi server") def get_access_token() -> str: @@ -108,45 +119,87 @@ def get_access_token() -> str: return response.json().get("access_token") -@pytest.fixture -def client_with_stomp() -> Generator[BlueapiClient]: - mock_session_manager = MagicMock - mock_session_manager.get_valid_access_token = get_access_token - with patch( - "blueapi.service.authentication.SessionManager.from_cache", - return_value=mock_session_manager, - ): - yield BlueapiClient.from_config( - config=ApplicationConfig( - stomp=StompConfig( - enabled=True, - auth=BasicAuthentication(username="guest", password="guest"), # type: ignore - ) - ) - ) +def load_config(path: Path) -> ApplicationConfig: + loader = ConfigLoader(ApplicationConfig) + loader.use_values_from_yaml(path) + return loader.load() @pytest.fixture(scope="module", autouse=True) -def wait_for_server(client: BlueapiClient): - for _ in range(20): +def device_flow_login(): + path = _DATA_PATH / "config-cli-without-stomp.yaml" + config = load_config(path) + assert config.auth_token_path + + token_path = Path(config.auth_token_path) + + if token_path.exists(): try: - client.get_environment() + SessionCacheManager(config.auth_token_path).load_cache() return - except ConnectionError: - ... - time.sleep(0.5) - raise TimeoutError("No connection to the blueapi server") + except Exception: + pass + + proc = subprocess.Popen( + ["uv", "run", "blueapi", "-c", str(path), "login"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + text=True, + ) + try: + url = None + assert proc.stdout + + for line in proc.stdout: + match = re.search(r"(https?://\S+)", line) + if match: + url = match.group(1) + break + + assert url, "No login URL printed" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + page.goto(url) + page.fill("input[name='username']", "admin") + page.fill("input[name='password']", "admin") + page.click("button[type='submit']") + page.get_by_role("button", name="Yes").click() + + browser.close() + + # wait for token + for _ in range(20): + if token_path.exists(): + try: + SessionCacheManager(config.auth_token_path).load_cache() + break + except Exception: + pass + time.sleep(0.25) + return + finally: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=5) + if proc.stdout: + proc.stdout.close() -@pytest.fixture(scope="module") -def client() -> Generator[BlueapiClient]: - mock_session_manager = MagicMock - mock_session_manager.get_valid_access_token = get_access_token - with patch( - "blueapi.service.authentication.SessionManager.from_cache", - return_value=mock_session_manager, - ): - yield BlueapiClient.from_config(config=ApplicationConfig()) + +@pytest.fixture +def client_with_stomp() -> BlueapiClient: + return BlueapiClient.from_config(config=load_config(_DATA_PATH / "config-cli.yaml")) + + +@pytest.fixture +def client() -> BlueapiClient: + return BlueapiClient.from_config( + config=load_config(_DATA_PATH / "config-cli-without-stomp.yaml") + ) @pytest.fixture @@ -179,22 +232,9 @@ def blueapi_client_get_methods() -> list[str]: ] -@pytest.fixture(autouse=True) -def clean_existing_tasks(client: BlueapiClient): - for task in client.get_all_tasks().tasks: - client.clear_task(task.task_id) - yield - - -@pytest.fixture(scope="module") -def server_config() -> ApplicationConfig: - loader = ConfigLoader(ApplicationConfig) - loader.use_values_from_yaml(Path("tests", "system_tests", "config.yaml")) - return loader.load() - - @pytest.fixture(autouse=True, scope="module") -def reset_numtracker(server_config: ApplicationConfig): +def reset_numtracker(): + server_config = load_config(Path("tests", "system_tests", "config.yaml")) nt_url = server_config.numtracker.url # type: ignore - if numtracker is None we should fail requests.post( str(nt_url), @@ -296,6 +336,9 @@ def test_create_task_validation_error(client: BlueapiClient): def test_get_all_tasks(client: BlueapiClient): + for task in client.get_all_tasks().tasks: + client.clear_task(task.task_id) + created_tasks: list[TaskResponse] = [] for task in [_SIMPLE_TASK, _LONG_TASK]: created_task = client.create_task(task) @@ -373,6 +416,8 @@ def test_set_state_transition_error(client: BlueapiClient): def test_get_task_by_status(client: BlueapiClient): + for task in client.get_all_tasks().tasks: + client.clear_task(task.task_id) task_1 = client.create_task(_SIMPLE_TASK) task_2 = client.create_task(_SIMPLE_TASK) task_by_pending = client.get_all_tasks() diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 2f31784aa..cdcd0930f 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -39,6 +39,7 @@ DodalSource, EnvironmentConfig, MetadataConfig, + OIDCConfig, PlanSource, TiledConfig, ) @@ -858,22 +859,56 @@ def test_setup_default_not_makes_tiled_inserter(): assert context.tiled_conf is None -@pytest.mark.parametrize("api_key", [None, "foo"]) -def test_setup_with_tiled_makes_tiled_inserter(api_key: str | None): - config = TiledConfig(enabled=True, api_key=api_key) +def test_setup_with_tiled_makes_tiled_inserter( + oidc_config: OIDCConfig, mock_authn_server +): + config = TiledConfig(enabled=True, token_exchange_secret="secret") context = BlueskyContext( ApplicationConfig( tiled=config, env=EnvironmentConfig(metadata=MetadataConfig(instrument="ixx")), + oidc=oidc_config, ) ) assert context.tiled_conf == config -@pytest.mark.parametrize("api_key", [None, "foo"]) -def test_must_have_instrument_set_for_tiled(api_key: str | None): - config = TiledConfig(enabled=True, api_key=api_key) - with pytest.raises(InvalidConfigError): +def test_must_have_instrument_set_for_tiled(): + config = TiledConfig(enabled=True) + with pytest.raises( + InvalidConfigError, + match="Tiled has been configured but `instrument` metadata is not set", + ): BlueskyContext( ApplicationConfig(tiled=config, env=EnvironmentConfig(metadata=None)) ) + + +def test_must_have_oidc_config_for_tiled(): + config = TiledConfig(enabled=True) + with pytest.raises( + InvalidConfigError, + match="Tiled has been configured but oidc configuration is missing", + ): + BlueskyContext( + ApplicationConfig( + tiled=config, + env=EnvironmentConfig(metadata=MetadataConfig(instrument="ixx")), + oidc=None, + ) + ) + + +def test_token_exchange_secret_is_set_for_tiled(oidc_config: OIDCConfig): + config = TiledConfig(enabled=True) + with pytest.raises( + InvalidConfigError, + match="Tiled has been enabled but Token exchange secret has not been", + ): + BlueskyContext( + ApplicationConfig( + tiled=config, + env=EnvironmentConfig(metadata=MetadataConfig(instrument="ixx")), + oidc=oidc_config, + ) + ) diff --git a/tests/unit_tests/service/test_authentication.py b/tests/unit_tests/service/test_authentication.py index e86dbc490..1b4ba357e 100644 --- a/tests/unit_tests/service/test_authentication.py +++ b/tests/unit_tests/service/test_authentication.py @@ -1,27 +1,36 @@ import os from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import httpx import jwt import pytest import responses +import respx +from pydantic import HttpUrl from starlette.status import HTTP_403_FORBIDDEN -from blueapi.config import OIDCConfig +from blueapi.config import OIDCConfig, TiledConfig from blueapi.service import main -from blueapi.service.authentication import SessionCacheManager, SessionManager +from blueapi.service.authentication import ( + SessionCacheManager, + SessionManager, + TiledAuth, + Token, + TokenType, +) @pytest.fixture -def auth_token_path(tmp_path) -> Path: +def auth_token_path(tmp_path: Path) -> Path: return tmp_path / "blueapi_cache" @pytest.fixture def session_manager( oidc_config: OIDCConfig, - auth_token_path, + auth_token_path: Path, mock_authn_server: responses.RequestsMock, ) -> SessionManager: return SessionManager( @@ -125,15 +134,180 @@ def test_server_raises_exception_for_invalid_token( def test_processes_valid_token( oidc_config: OIDCConfig, mock_authn_server: responses.RequestsMock, - valid_token_with_jwt, + valid_token_with_jwt: dict[str, Any], ): inner = main.verify_access_token(oidc_config) inner(access_token=valid_token_with_jwt["access_token"]) -def test_session_cache_manager_returns_writable_file_path(tmp_path): +def test_session_cache_manager_returns_writable_file_path(tmp_path: Path): os.environ["XDG_CACHE_HOME"] = str(tmp_path) cache = SessionCacheManager(token_path=None) Path(cache._file_path).touch() assert os.path.isfile(cache._file_path) assert cache._file_path == f"{tmp_path}/blueapi_cache" + + +@pytest.fixture +def token_exchange_response(): + return { + "access_token": "token-exchange-access-token", + "expires_in": 900, + "issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token", + "not-before-policy": 0, + "refresh_expires_in": 1800, + "refresh_token": "token-exchange-refresh-token", + "scope": "openid profile email fedid", + "session_state": "c1311c2b-a4e1-456b-2ff7-9f0a7e2f516b", + "token_type": "Bearer", + } + + +@pytest.fixture +def refresh_token_response(): + return { + "access_token": "new-access-token", + "expires_in": 900, + "refresh_expires_in": 1800, + "refresh_token": "token-exchange-refresh-token", + "not-before-policy": 0, + "session_state": "c1311c2b-a4e1-406b-9ff7-9f0a7e2f516b", + "scope": "openid profile email fedid", + } + + +@pytest.fixture +def tiled_url() -> str: + return "http://tiled.com" + + +@pytest.fixture +def blueapi_jwt(): + return "blueapi-token" + + +@pytest.fixture +def tiled_config(oidc_config: OIDCConfig, mock_authn_server, tiled_url: str): + return TiledConfig( + enabled=True, + url=HttpUrl(tiled_url), + token_url=oidc_config.token_endpoint, + token_exchange_client_id="token_exchange_id", + token_exchange_secret="secret", + ) + + +@pytest.fixture() +def tiled_auth(tiled_config, blueapi_jwt): + return TiledAuth( + tiled_config=tiled_config, + blueapi_jwt_token=blueapi_jwt, + ) + + +@pytest.fixture +def mock_token_exchange( + oidc_config: OIDCConfig, + token_exchange_response, + refresh_token_response, + tiled_config, + blueapi_jwt, +): + token_exchange_data = { + "client_id": tiled_config.token_exchange_client_id, + "client_secret": tiled_config.token_exchange_secret, + "subject_token": blueapi_jwt, + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token", + } + with respx.mock( + base_url=oidc_config.token_endpoint, assert_all_called=False + ) as respx_mock: + exchange_token = respx_mock.post( + name="exchange_tokens", + data=token_exchange_data, + ) + exchange_token.return_value = httpx.Response(200, json=token_exchange_response) + + token_exchange_refresh_data = { + "client_id": tiled_config.token_exchange_client_id, + "client_secret": tiled_config.token_exchange_secret, + "grant_type": "refresh_token", + "refresh_token": token_exchange_response["refresh_token"], + } + refresh_token = respx_mock.post( + name="refresh_tokens", + data=token_exchange_refresh_data, + ) + refresh_token.return_value = httpx.Response(200, json=refresh_token_response) + + yield respx_mock + + +@respx.mock +def test_blueapi_token_exchange( + tiled_auth: TiledAuth, tiled_url: str, mock_token_exchange +): + respx.get(tiled_url).mock(side_effect=[httpx.Response(200), httpx.Response(200)]) + with httpx.Client(auth=tiled_auth) as tiled_client: + tiled_client.get(tiled_url) + assert mock_token_exchange["exchange_tokens"].called + assert not mock_token_exchange["refresh_tokens"].called + tiled_client.get(tiled_url) + + +@respx.mock +def test_blueapi_token_exchange_refresh_token( + tiled_auth: TiledAuth, tiled_url: str, mock_token_exchange +): + respx.get(tiled_url).mock( + side_effect=[httpx.Response(200), httpx.Response(401), httpx.Response(200)] + ) + with httpx.Client(auth=tiled_auth) as tiled_client: + tiled_client.get(tiled_url) + assert mock_token_exchange["exchange_tokens"].called + assert not mock_token_exchange["refresh_tokens"].called + # Make access token expired + tiled_auth._access_token = MagicMock() + tiled_auth._access_token.expired = True + tiled_client.get(tiled_url) + assert mock_token_exchange["refresh_tokens"].called + + +@respx.mock +def test_blueapi_token_exchange_refresh_token_exception( + tiled_auth: TiledAuth, tiled_url: str, mock_token_exchange +): + respx.get(tiled_url).mock( + side_effect=[httpx.Response(200), httpx.Response(401), httpx.Response(200)] + ) + with httpx.Client(auth=tiled_auth) as tiled_client: + tiled_client.get(tiled_url) + assert mock_token_exchange["exchange_tokens"].called + assert not mock_token_exchange["refresh_tokens"].called + # Make access token expired + tiled_auth._access_token = MagicMock() + tiled_auth._access_token.expired = True + # Make refresh token None + tiled_auth._refresh_token = None + with pytest.raises( + Exception, match="Cannot refresh session as no refresh token available" + ): + tiled_client.get(tiled_url) + assert not mock_token_exchange["refresh_tokens"].called + + +def test_token_is_assumed_valid_if_information_not_available(): + access_token = Token( + token_dict={"access_token": "foo", "refresh_token": "bar"}, + token_type=TokenType.access_token, + ) + assert not access_token.expired + + +def test_token_raise_value_error_if_not_found(): + with pytest.raises( + ValueError, match=f"Not able to find {TokenType.access_token} in response" + ): + Token(token_dict={"refresh_token": "bar"}, token_type=TokenType.access_token) diff --git a/tests/unit_tests/service/test_interface.py b/tests/unit_tests/service/test_interface.py index e3cda0b5a..d3c5e1933 100644 --- a/tests/unit_tests/service/test_interface.py +++ b/tests/unit_tests/service/test_interface.py @@ -31,6 +31,7 @@ ) from blueapi.core.context import BlueskyContext from blueapi.service import interface +from blueapi.service.constants import AUTHORIZAITON_HEADER from blueapi.service.model import ( DeviceModel, PackageInfo, @@ -377,7 +378,9 @@ def test_remove_tiled_subscriber(worker, context, from_uri, writer): context().run_engine.subscribe.return_value = 17 worker().worker_events.subscribe.return_value = 42 - interface.begin_task(task) + interface.begin_task( + task, pass_through_headers={AUTHORIZAITON_HEADER: "Bearer blueapi_token"} + ) writer.assert_called_once_with(from_uri(), batch_size=1) context().run_engine.subscribe.assert_called_once_with(writer()) @@ -664,3 +667,32 @@ def test_update_scan_num_side_effect_sets_scan_file_in_re_md( ctx.run_engine.scan_id_source(ctx.run_engine.md) assert ctx.run_engine.md["scan_file"] == "p46-11" + + +@patch("blueapi.service.interface.context") +@patch("blueapi.service.interface.worker") +def test_tiled_raises_if_authorization_header_missing(worker, context): + task = WorkerTask(task_id="foo_bar") + context().numtracker = None + context().tiled_conf = TiledConfig() + + with pytest.raises( + ValueError, + match="Tiled config is enabled but no authorization header in request", + ): + interface.begin_task(task, pass_through_headers=None) + + +@patch("blueapi.service.interface.context") +@patch("blueapi.service.interface.worker") +def test_tiled_raises_if_bearer_token_missing(worker, context): + task = WorkerTask(task_id="foo_bar") + context().numtracker = None + context().tiled_conf = TiledConfig() + + with pytest.raises( + KeyError, match="Tiled config is enabled but no Bearer Token in request" + ): + interface.begin_task( + task, pass_through_headers={AUTHORIZAITON_HEADER: "Bearer"} + ) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 26621502a..6e2a712d9 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -288,8 +288,10 @@ def test_config_yaml_parsed(temp_yaml_config_file): "auth": {"username": "guest", "password": "guest"}, }, "tiled": { - "api_key": None, "enabled": False, + "token_exchange_client_id": "", + "token_exchange_secret": "", + "token_url": "", "url": "http://localhost:8407/", }, "auth_token_path": None, @@ -345,9 +347,11 @@ def test_config_yaml_parsed(temp_yaml_config_file): "auth": {"username": "guest", "password": "guest"}, }, "tiled": { - "api_key": None, "enabled": False, "url": "http://localhost:8407/", + "token_exchange_client_id": "", + "token_exchange_secret": "", + "token_url": "", }, "auth_token_path": None, "env": { diff --git a/uv.lock b/uv.lock index b8d15cd21..ccd2c2add 100644 --- a/uv.lock +++ b/uv.lock @@ -462,13 +462,16 @@ dev = [ { name = "jwcrypto" }, { name = "mock" }, { name = "myst-parser" }, + { name = "playwright" }, { name = "pre-commit" }, { name = "pydata-sphinx-theme" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-playwright" }, { name = "responses" }, + { name = "respx" }, { name = "ruff" }, { name = "semver" }, { name = "sphinx-autobuild" }, @@ -507,7 +510,7 @@ requires-dist = [ { name = "scanspec", specifier = ">=0.9.0" }, { name = "stomp-py" }, { name = "super-state-machine" }, - { name = "tiled", extras = ["client"], specifier = ">=0.2.3" }, + { name = "tiled", extras = ["client"], git = "https://github.com/bluesky/tiled.git?rev=add-auth-for-client" }, { name = "tomlkit" }, { name = "uvicorn" }, ] @@ -519,13 +522,16 @@ dev = [ { name = "jwcrypto" }, { name = "mock" }, { name = "myst-parser" }, + { name = "playwright" }, { name = "pre-commit" }, { name = "pydata-sphinx-theme", specifier = ">=0.15.4" }, { name = "pyright", specifier = "!=1.1.407" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-playwright" }, { name = "responses" }, + { name = "respx" }, { name = "ruff" }, { name = "semver" }, { name = "sphinx-autobuild", specifier = ">=2024.4.16" }, @@ -533,7 +539,7 @@ dev = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinxcontrib-openapi" }, - { name = "tiled", extras = ["minimal-server"], specifier = ">=0.2.3" }, + { name = "tiled", extras = ["minimal-server"], git = "https://github.com/bluesky/tiled.git?rev=add-auth-for-client" }, { name = "tox-uv" }, { name = "types-mock" }, { name = "types-pyyaml" }, @@ -3644,6 +3650,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "playwright" +version = "1.57.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" }, + { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" }, + { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -4106,6 +4131,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pyepics" version = "3.5.9" @@ -4205,6 +4242,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -4219,6 +4269,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-playwright" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -4268,6 +4333,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -4475,6 +4552,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -5226,9 +5315,18 @@ wheels = [ ] [[package]] -name = "tiled" -version = "0.2.3" +name = "text-unidecode" +version = "1.3" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "tiled" +version = "0.2.4.dev2+g306833209" +source = { git = "https://github.com/bluesky/tiled.git?rev=add-auth-for-client#3068332094c0c1f5588683d2bab9e0282d51ad44" } dependencies = [ { name = "httpx" }, { name = "json-merge-patch" }, @@ -5242,10 +5340,6 @@ dependencies = [ { name = "pyyaml" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/1e/cc60843c6f40655718dcf2fc9947e2b360fdfa40d4374db361643c1e5341/tiled-0.2.3.tar.gz", hash = "sha256:ce6a8acc5047e767dcc70520d166b10e0840f3e2b9970a06dcf7f9f9f1fb3f7b", size = 2493279, upload-time = "2025-12-17T20:36:45.781Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/3c/5098fb82017078cd7324b78c34f136d389e44251719f6332abbad455815d/tiled-0.2.3-py3-none-any.whl", hash = "sha256:f926cd097a430739ad0baddb45540aaeeb868eba9650100e9ec0200ddb866ba5", size = 1846157, upload-time = "2025-12-17T20:36:42.93Z" }, -] [package.optional-dependencies] client = [