diff --git a/eufy_security/api.py b/eufy_security/api.py index 3fd076f..48285f5 100644 --- a/eufy_security/api.py +++ b/eufy_security/api.py @@ -7,7 +7,7 @@ from aiohttp.client_exceptions import ClientError from .camera import Camera -from .errors import RequestError, raise_error +from .errors import InvalidCredentialsError, RequestError, raise_error _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -21,9 +21,10 @@ def __init__(self, email: str, password: str, websession: ClientSession) -> None """Initialize.""" self._email: str = email self._password: str = password + self._retry_on_401: bool = False + self._session: ClientSession = websession self._token: Optional[str] = None self._token_expiration: Optional[datetime] = None - self._session: ClientSession = websession self.cameras: Dict[str, Camera] = {} @@ -35,6 +36,7 @@ async def async_authenticate(self) -> None: json={"email": self._email, "password": self._password}, ) + self._retry_on_401 = False self._token = auth_resp["data"]["auth_token"] self._token_expiration = datetime.fromtimestamp( auth_resp["data"]["token_expires_at"] @@ -92,9 +94,18 @@ async def request( return data except ClientError as err: + if "401" in str(err): + if self._retry_on_401: + raise InvalidCredentialsError("Token failed multiple times") + + self._retry_on_401 = True + await self.async_authenticate() + return await self.request( + method, endpoint, headers=headers, json=json + ) raise RequestError( f"There was an unknown error while requesting {endpoint}: {err}" - ) + ) from None def _raise_on_error(data: dict) -> None: diff --git a/tests/test_api.py b/tests/test_api.py index 87657b7..948b4e6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -19,6 +19,70 @@ ) +@pytest.mark.asyncio +async def test_401_refresh_failure( + aresponses, devices_list_json, event_loop, login_success_json +): + """Test that multiple 401 responses in a row raises the right exception.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=None, status=401), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=None, status=401), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + with pytest.raises(InvalidCredentialsError): + await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + + +@pytest.mark.asyncio +async def test_401_refresh_success( + aresponses, devices_list_json, event_loop, login_success_json +): + """Test that a 401 response re-authenticates successfully.""" + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=401), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + + async with aiohttp.ClientSession(loop=event_loop) as websession: + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + assert len(api.cameras) == 2 + + @pytest.mark.asyncio async def test_bad_email(aresponses, event_loop, login_invalid_email_json): """Test authenticating with a bad email.""" @@ -73,18 +137,40 @@ async def test_empty_response( @pytest.mark.asyncio -async def test_http_error(aresponses, event_loop, login_success_json): - """Test the Eufy Security web API returning a non-2xx HTTP error code.""" +async def test_expired_access_token( + aresponses, devices_list_json, event_loop, login_success_json +): + """Test that an expired access token refreshes automatically and correctly.""" aresponses.add( "mysecurity.eufylife.com", "/api/v1/passport/login", "post", - aresponses.Response(text=None, status=500), + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/passport/login", + "post", + aresponses.Response(text=json.dumps(login_success_json), status=200), + ) + aresponses.add( + "mysecurity.eufylife.com", + "/api/v1/app/get_devs_list", + "post", + aresponses.Response(text=json.dumps(devices_list_json), status=200), ) async with aiohttp.ClientSession(loop=event_loop) as websession: - with pytest.raises(RequestError): - await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) + api._token_expiration = datetime.now() - timedelta(seconds=10) + await api.async_update_device_info() + assert len(api.cameras) == 2 @pytest.mark.asyncio @@ -118,49 +204,25 @@ async def test_get_history( @pytest.mark.asyncio -async def test_login_success( - aresponses, devices_list_json, event_loop, login_success_json -): - """Test a successful login and API object creation.""" +async def test_http_error(aresponses, event_loop, login_success_json): + """Test the Eufy Security web API returning a non-2xx HTTP error code.""" aresponses.add( "mysecurity.eufylife.com", "/api/v1/passport/login", "post", - aresponses.Response(text=json.dumps(login_success_json), status=200), - ) - aresponses.add( - "mysecurity.eufylife.com", - "/api/v1/app/get_devs_list", - "post", - aresponses.Response(text=json.dumps(devices_list_json), status=200), + aresponses.Response(text=None, status=500), ) async with aiohttp.ClientSession(loop=event_loop) as websession: - api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) - assert api._email == TEST_EMAIL - assert api._password == TEST_PASSWORD - assert api._token is not None - assert api._token_expiration is not None - assert len(api.cameras) == 2 + with pytest.raises(RequestError): + await async_login(TEST_EMAIL, TEST_PASSWORD, websession) @pytest.mark.asyncio -async def test_refreshing_access_token( +async def test_login_success( aresponses, devices_list_json, event_loop, login_success_json ): - """Test that an expired access token refreshes automatically and correctly.""" - aresponses.add( - "mysecurity.eufylife.com", - "/api/v1/passport/login", - "post", - aresponses.Response(text=json.dumps(login_success_json), status=200), - ) - aresponses.add( - "mysecurity.eufylife.com", - "/api/v1/app/get_devs_list", - "post", - aresponses.Response(text=json.dumps(devices_list_json), status=200), - ) + """Test a successful login and API object creation.""" aresponses.add( "mysecurity.eufylife.com", "/api/v1/passport/login", @@ -176,6 +238,8 @@ async def test_refreshing_access_token( async with aiohttp.ClientSession(loop=event_loop) as websession: api = await async_login(TEST_EMAIL, TEST_PASSWORD, websession) - api._token_expiration = datetime.now() - timedelta(seconds=10) - await api.async_update_device_info() + assert api._email == TEST_EMAIL + assert api._password == TEST_PASSWORD + assert api._token is not None + assert api._token_expiration is not None assert len(api.cameras) == 2