Skip to content

Commit

Permalink
Merge pull request #7 from canonical/webinteractor
Browse files Browse the repository at this point in the history
store client: use in-house WebBrowserInteractor for candid (CRAFT-563)
  • Loading branch information
sergiusens committed Oct 6, 2021
2 parents 7f2f39e + 96b2086 commit df95bcf
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 5 deletions.
3 changes: 2 additions & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ disable=print-statement,
exception-escape,
comprehension-escape,
line-too-long,
fixme
fixme,
fixme-info

# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
Expand Down
21 changes: 21 additions & 0 deletions craft_store/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,24 @@ class NotLoggedIn(CraftStoreError):

def __init__(self, message: str) -> None:
super().__init__(f"Not logged in: {message}.")


class CandidTokenTimeoutError(CraftStoreError):
"""Error raised when timeout is reached trying to discharge a macaroon."""

def __init__(self, url: str) -> None:
super().__init__(f"Timed out waiting for token response from {url!r}.")


class CandidTokenKindError(CraftStoreError):
"""Error raised when the token kind is missing from the discharged macaroon."""

def __init__(self, url: str) -> None:
super().__init__(f"Empty token kind returned from {url!r}.")


class CandidTokenValueError(CraftStoreError):
"""Error raised when the token value is missing from the discharged macaroon."""

def __init__(self, url: str) -> None:
super().__init__(f"Empty token value returned from {url!r}.")
40 changes: 39 additions & 1 deletion craft_store/store_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from macaroonbakery import bakery, httpbakery
from pymacaroons.serializers import json_serializer

from . import errors
from .auth import Auth
from .http_client import HTTPClient

Expand All @@ -36,6 +37,40 @@ def _macaroon_to_json_string(macaroon) -> str:
return macaroon.serialize(json_serializer.JsonSerializer())


class WebBrowserWaitingInteractor(httpbakery.WebBrowserInteractor):
"""WebBrowserInteractor implementation using HTTPClient.
Waiting for a token is implemented using HTTPClient which mounts
a session with backoff retries.
Better exception classes and messages are provided to handle errors.
"""

def __init__(self, user_agent: str) -> None:
super().__init__()
self.user_agent = user_agent

# TODO: transfer implementation to macaroonbakery.
def _wait_for_token(self, ctx, wait_token_url):
request_client = HTTPClient(user_agent=self.user_agent)
resp = request_client.request("GET", wait_token_url)
if resp.status_code != 200:
raise errors.CandidTokenTimeoutError(url=wait_token_url)
json_resp = resp.json()
kind = json_resp.get("kind")
if kind is None:
raise errors.CandidTokenKindError(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.CandidTokenValueError(url=wait_token_url)
token_val = base64.b64decode(token_val)
return httpbakery._interactor.DischargeToken( # pylint: disable=W0212
kind=kind, value=token_val
)


class StoreClient(HTTPClient):
"""Encapsulates API calls for the Snap Store or Charmhub."""

Expand All @@ -56,7 +91,9 @@ def __init__(
"""
super().__init__(user_agent=user_agent)

self._bakery_client = httpbakery.Client()
self._bakery_client = httpbakery.Client(
interaction_methods=[WebBrowserWaitingInteractor(user_agent=user_agent)]
)
self._base_url = base_url
self._store_host = urlparse(base_url).netloc
self._endpoints = endpoints
Expand Down Expand Up @@ -89,6 +126,7 @@ def _authorize_token(self, candid_discharged_macaroon: str) -> str:
"POST",
self._base_url + self._endpoints.tokens_exchange,
headers={"Macaroons": candid_discharged_macaroon},
json={},
)

return token_exchange_response.json()["macaroon"]
Expand Down
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ install_requires =
pydantic
keyring
macaroonbakery
requests

[options.package_data]
craft_store = py.typed
Expand Down Expand Up @@ -96,6 +97,9 @@ ignore_missing_imports = True
[mypy-pymacaroons.*]
ignore_missing_imports = True

[mypy-urllib3.*]
ignore_missing_imports = True

[pydantic-mypy]
init_forbid_extra = True
init_typed = True
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ def _fake_error_response(status_code, reason):
"args": ["no credentials"],
"expected_message": "Not logged in: no credentials.",
},
{
"exception_class": errors.CandidTokenTimeoutError,
"args": ["https://foo.bar"],
"expected_message": "Timed out waiting for token response from 'https://foo.bar'.",
},
{
"exception_class": errors.CandidTokenKindError,
"args": ["https://foo.bar"],
"expected_message": "Empty token kind returned from 'https://foo.bar'.",
},
{
"exception_class": errors.CandidTokenValueError,
"args": ["https://foo.bar"],
"expected_message": "Empty token value returned from 'https://foo.bar'.",
},
)


Expand Down
60 changes: 57 additions & 3 deletions tests/unit/test_store_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import json
from unittest.mock import Mock, call, patch
from unittest.mock import ANY, Mock, call, patch

import pytest
from macaroonbakery import bakery, httpbakery
from pymacaroons.macaroon import Macaroon

from craft_store import endpoints
from craft_store.store_client import StoreClient
from craft_store import endpoints, errors
from craft_store.store_client import StoreClient, WebBrowserWaitingInteractor


def _fake_response(status_code, reason=None, json=None):
Expand Down Expand Up @@ -140,6 +140,7 @@ def test_store_client_login(
headers={
"Macaroons": "W3siaWRlbnRpZmllciI6ICIiLCAic2lnbmF0dXJlIjogImQ5NTMzNDYxZDc4MzVlNDg1MWM3ZTNiNjM5MTQ0NDA2Y2Y3Njg1OTdkZWE2ZTEzMzIzMmZiZDIzODVhNWMwNTAiLCAibG9jYXRpb24iOiAiZmFrZS1zZXJ2ZXIuY29tIn1d"
},
json={},
),
]

Expand Down Expand Up @@ -219,3 +220,56 @@ def test_store_client_whoami(http_client_request_mock, real_macaroon, auth_mock)
call("fakecraft", "https://fake-server.com"),
call().get_credentials(),
]


def test_webinteractore_wait_for_token(http_client_request_mock):
http_client_request_mock.side_effect = None
http_client_request_mock.return_value = _fake_response(
200, json={"kind": "kind", "token": "TOKEN", "token64": b"VE9LRU42NA=="}
)

wbi = WebBrowserWaitingInteractor(user_agent="foobar")

discharged_token = wbi._wait_for_token( # pylint: disable=W0212
object(), "https://foo.bar/candid"
)

assert discharged_token == httpbakery.DischargeToken(kind="kind", value="TOKEN")
assert http_client_request_mock.mock_calls == [
call(ANY, "GET", "https://foo.bar/candid")
]


def test_webinteractore_wait_for_token_timeout_error(http_client_request_mock):
http_client_request_mock.side_effect = None
http_client_request_mock.return_value = _fake_response(400, json={})

wbi = WebBrowserWaitingInteractor(user_agent="foobar")

with pytest.raises(errors.CandidTokenTimeoutError):
wbi._wait_for_token(object(), "https://foo.bar/candid") # pylint: disable=W0212


def test_webinteractore_wait_for_token_kind_error(http_client_request_mock):
http_client_request_mock.side_effect = None
http_client_request_mock.return_value = _fake_response(200, json={})

wbi = WebBrowserWaitingInteractor(user_agent="foobar")

with pytest.raises(errors.CandidTokenKindError):
wbi._wait_for_token(object(), "https://foo.bar/candid") # pylint: disable=W0212


def test_webinteractore_wait_for_token_value_error(http_client_request_mock):
http_client_request_mock.side_effect = None
http_client_request_mock.return_value = _fake_response(
200,
json={
"kind": "kind",
},
)

wbi = WebBrowserWaitingInteractor(user_agent="foobar")

with pytest.raises(errors.CandidTokenValueError):
wbi._wait_for_token(object(), "https://foo.bar/candid") # pylint: disable=W0212
3 changes: 3 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ commands = make test-integrations

[testenv:units]
commands = make test-units

[pycodestyle]
ignore = E501

0 comments on commit df95bcf

Please sign in to comment.