diff --git a/snapcraft/storeapi/http_clients/_candid_client.py b/snapcraft/storeapi/http_clients/_candid_client.py index adc6330dbf..93de96b389 100644 --- a/snapcraft/storeapi/http_clients/_candid_client.py +++ b/snapcraft/storeapi/http_clients/_candid_client.py @@ -11,7 +11,35 @@ from xdg import BaseDirectory from snapcraft.storeapi import constants -from . import agent, _config, _http_client +from . import agent, errors, _config, _http_client + + +class WebBrowserWaitingInteractor(httpbakery.WebBrowserInteractor): + """WebBrowserInteractor implementation using .http_client.Client. + + Waiting for a token is implemented using _http_client.Client which mounts + a session with backoff retires. + + Better exception classes and messages are provided to handle errors. + """ + + # TODO: transfer implementation to macaroonbakery. + def _wait_for_token(self, ctx, wait_token_url): + request_client = _http_client.Client() + resp = request_client.request("GET", wait_token_url) + if resp.status_code != 200: + raise errors.TokenTimeoutError(url=wait_token_url) + json_resp = resp.json() + kind = json_resp.get("kind") + if kind is None: + raise errors.TokenKindError(url=wait_token_url) + token_val = json_resp.get("token") + if token_val is None: + token_val = json_resp.get("token64") + if token_val is None: + raise errors.TokenValueError(url=wait_token_url) + token_val = base64.b64decode(token_val) + return httpbakery._interactor.DischargeToken(kind=kind, value=token_val) class CandidConfig(_config.Config): @@ -57,14 +85,16 @@ def _auth(self, auth: str) -> None: self._conf.save() def __init__( - self, - *, - user_agent: str = agent.get_user_agent(), - bakery_client=httpbakery.Client(), + self, *, user_agent: str = agent.get_user_agent(), bakery_client=None ) -> None: super().__init__(user_agent=user_agent) - self.bakery_client = bakery_client + if bakery_client is None: + self.bakery_client = httpbakery.Client( + interaction_methods=[WebBrowserWaitingInteractor()] + ) + else: + self.bakery_client = bakery_client self._conf = CandidConfig() self._conf_save = True diff --git a/snapcraft/storeapi/http_clients/errors.py b/snapcraft/storeapi/http_clients/errors.py index 7e2c34d453..41d2029328 100644 --- a/snapcraft/storeapi/http_clients/errors.py +++ b/snapcraft/storeapi/http_clients/errors.py @@ -119,3 +119,18 @@ class InvalidLoginConfig(HttpClientError): def __init__(self, error): super().__init__(error=error) + + +class TokenTimeoutError(SnapcraftError): + def __init__(self, *, url: str) -> None: + self.fmt = f"Timed out waiting for token response from {url!r}." + + +class TokenKindError(SnapcraftError): + def __init__(self, *, url: str) -> None: + self.fmt = f"Empty token kind returned from {url!r}." + + +class TokenValueError(SnapcraftError): + def __init__(self, *, url: str) -> None: + self.fmt = f"Empty token value returned from {url!r}." diff --git a/tests/unit/store/http_client/test_candid_client.py b/tests/unit/store/http_client/test_candid_client.py index 1da65e1de5..76390322c6 100644 --- a/tests/unit/store/http_client/test_candid_client.py +++ b/tests/unit/store/http_client/test_candid_client.py @@ -26,6 +26,8 @@ from snapcraft.storeapi.http_clients._candid_client import ( CandidClient, CandidConfig, + WebBrowserWaitingInteractor, + errors, _http_client, ) @@ -132,7 +134,7 @@ def test_login_with_config_fd(candid_client, snapcraft_macaroon): with io.StringIO() as config_fd: print("[dashboard.snapcraft.io]", file=config_fd) print(f"macaroon = {snapcraft_macaroon}", file=config_fd) - print(f"auth = 1234567890noshare", file=config_fd) + print("auth = 1234567890noshare", file=config_fd) config_fd.seek(0) candid_client.login(config_fd=config_fd) @@ -146,7 +148,7 @@ def test_login_with_config_fd_no_save(candid_client, snapcraft_macaroon): with io.StringIO() as config_fd: print("[dashboard.snapcraft.io]", file=config_fd) print(f"macaroon = {snapcraft_macaroon}", file=config_fd) - print(f"auth = 1234567890noshare", file=config_fd) + print("auth = 1234567890noshare", file=config_fd) config_fd.seek(0) candid_client.login(config_fd=config_fd, save=False) @@ -222,6 +224,72 @@ def request_mock(): patched.stop() +@pytest.fixture +def token_response_mock(): + class Response: + MOCK_JSON = { + "kind": "kind", + "token": "TOKEN", + "token64": b"VE9LRU42NA==", + } + + status_code = 200 + + def json(self): + return self.MOCK_JSON + + return Response() + + +def test_wait_for_token_success(request_mock, token_response_mock): + request_mock.return_value = token_response_mock + + wbi = WebBrowserWaitingInteractor() + discharged_token = wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") + + assert discharged_token.kind == "kind" + assert discharged_token.value == "TOKEN" + + +def test_wait_for_token64_success(request_mock, token_response_mock): + token_response_mock.MOCK_JSON.pop("token") + request_mock.return_value = token_response_mock + + wbi = WebBrowserWaitingInteractor() + discharged_token = wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") + + assert discharged_token.kind == "kind" + assert discharged_token.value == b"TOKEN64" + + +def test_wait_for_token_requests_status_not_200(request_mock, token_response_mock): + token_response_mock.status_code = 504 + request_mock.return_value = token_response_mock + + wbi = WebBrowserWaitingInteractor() + with pytest.raises(errors.TokenTimeoutError): + wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") + + +def test_wait_for_token_requests_no_kind(request_mock, token_response_mock): + token_response_mock.MOCK_JSON.pop("kind") + request_mock.return_value = token_response_mock + + wbi = WebBrowserWaitingInteractor() + with pytest.raises(errors.TokenKindError): + wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") + + +def test_wait_for_token_requests_no_token(request_mock, token_response_mock): + token_response_mock.MOCK_JSON.pop("token") + token_response_mock.MOCK_JSON.pop("token64") + request_mock.return_value = token_response_mock + + wbi = WebBrowserWaitingInteractor() + with pytest.raises(errors.TokenValueError): + wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") + + @pytest.mark.parametrize("method", ["GET", "PUT", "POST"]) @pytest.mark.parametrize("params", [None, {}, {"foo": "bar"}]) def test_request(authed_client, request_mock, method, params): diff --git a/tests/unit/store/http_client/test_errors.py b/tests/unit/store/http_client/test_errors.py index e4dbee5e43..69453870d9 100644 --- a/tests/unit/store/http_client/test_errors.py +++ b/tests/unit/store/http_client/test_errors.py @@ -106,6 +106,32 @@ class TestSnapcraftException: ), }, ), + ( + "TokenTimeoutError", + { + "exception_class": errors.TokenTimeoutError, + "kwargs": {"url": "https://foo"}, + "expected_message": ( + "Timed out waiting for token response from 'https://foo'." + ), + }, + ), + ( + "TokenKindError", + { + "exception_class": errors.TokenKindError, + "kwargs": {"url": "https://foo"}, + "expected_message": ("Empty token kind returned from 'https://foo'."), + }, + ), + ( + "TokenValueError", + { + "exception_class": errors.TokenValueError, + "kwargs": {"url": "https://foo"}, + "expected_message": ("Empty token value returned from 'https://foo'."), + }, + ), ) def test_error_formatting(self, exception_class, expected_message, kwargs):