Skip to content

Commit

Permalink
Merge pull request #3562 from snapcore/macaroonbakery-interaction-error
Browse files Browse the repository at this point in the history
storeapi: improve candid interaction errors (CRAFT-135)
  • Loading branch information
sergiusens authored Jul 27, 2021
2 parents 656fa8a + e2640b1 commit 5478104
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 8 deletions.
42 changes: 36 additions & 6 deletions snapcraft/storeapi/http_clients/_candid_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions snapcraft/storeapi/http_clients/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
72 changes: 70 additions & 2 deletions tests/unit/store/http_client/test_candid_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from snapcraft.storeapi.http_clients._candid_client import (
CandidClient,
CandidConfig,
WebBrowserWaitingInteractor,
errors,
_http_client,
)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/store/http_client/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit 5478104

Please sign in to comment.