diff --git a/tconnectsync/api/common.py b/tconnectsync/api/common.py index 2ee889d..f92dd36 100644 --- a/tconnectsync/api/common.py +++ b/tconnectsync/api/common.py @@ -127,7 +127,7 @@ def split_days_range(start_a, end_a, days: int = 5) -> List[Tuple[str, str]]: class ApiException(Exception): def __init__(self, status_code, text, *args, **kwargs): self.status_code = status_code - super().__init__('%s (HTTP %s)' % (text, status_code), *args, **kwargs) + super().__init__('%s%s' % (text, ' (HTTP %s)' % status_code if status_code else ''), *args, **kwargs) class ApiLoginException(ApiException): pass \ No newline at end of file diff --git a/tconnectsync/api/controliq.py b/tconnectsync/api/controliq.py index 10527c2..029ab94 100644 --- a/tconnectsync/api/controliq.py +++ b/tconnectsync/api/controliq.py @@ -5,7 +5,7 @@ import logging from bs4 import BeautifulSoup -from ..util import timeago +from ..util import timeago, cap_length from .common import parse_date, base_headers, base_session, ApiException, ApiLoginException logger = logging.getLogger(__name__) @@ -14,7 +14,7 @@ class ControlIQApi: BASE_URL = 'https://tdcservices.tandemdiabetes.com/' LOGIN_URL = 'https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f' - LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.14.0.1' + LAST_CONFIRMED_SOFTWARE_VERSION = 't:connect 7.15.0.1' userGuid = None accessToken = None @@ -34,12 +34,20 @@ class ControlIQApi: data = self._build_login_data(email, password, soup) req = s.post(self.LOGIN_URL, data=data, headers={'Referer': self.LOGIN_URL, **base_headers()}, allow_redirects=False) + # HTTP 200 is reported when credentials are incorrect + if req.status_code == 200: + login_error = self._find_login_error(req.text) + if not login_error: + login_error = 'Check your login credentials.' + raise ApiLoginException(None, 'Error logging in to t:connect: %s' % login_error) + if req.status_code != 302: - raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.') + raise ApiLoginException(req.status_code, 'Error logging in to t:connect') fwd = s.post(urllib.parse.urljoin(self.LOGIN_URL, req.headers['Location']), cookies=req.cookies, headers=base_headers()) if fwd.status_code != 200: + logger.warn("Received non-HTTP 200: %s" % req.text) raise ApiException(fwd.status_code, 'Error retrieving t:connect login cookies.') self.userGuid = req.cookies['UserGUID'] @@ -79,6 +87,14 @@ class ControlIQApi: "txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password) } + def _find_login_error(self, text): + try: + soup = BeautifulSoup(text, features='lxml') + notice_error = soup.select_one(".notice_error").text.strip() + return notice_error + except Exception: + return None + def needs_relogin(self): diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get()) return (diff.seconds <= 5 * 60) diff --git a/tconnectsync/util/__init__.py b/tconnectsync/util/__init__.py index 4f2f8b8..8758c22 100644 --- a/tconnectsync/util/__init__.py +++ b/tconnectsync/util/__init__.py @@ -28,4 +28,9 @@ def removesuffix(input_string, suffix): def removeprefix(input_string, prefix): if prefix and input_string.startswith(prefix): return input_string[len(prefix):] - return input_string \ No newline at end of file + return input_string + +def cap_length(text, maxlen): + if not text or len(text) <= maxlen: + return text + return '%s[...]%s' % (text[:maxlen//2], text[maxlen//-2:]) \ No newline at end of file diff --git a/tests/api/test_controliq.py b/tests/api/test_controliq.py index 23de473..2c05ed3 100644 --- a/tests/api/test_controliq.py +++ b/tests/api/test_controliq.py @@ -111,12 +111,65 @@ class TestControlIQApi(unittest.TestCase): text=post_callback) - self.assertRaises(ApiLoginException, ciq.login, 'email@email.com', 'password') + self.assertRaisesRegex(ApiLoginException, 'Error logging in to t:connect: Check your login credentials.', ciq.login, 'email@email.com', 'password') self.assertIsNone(ciq.userGuid) self.assertIsNone(ciq.accessToken) self.assertIsNone(ciq.accessTokenExpiresAt) + def test_login_invalid_credentials_parsed_message(self): + ciq = ControlIQApi() + ciq.LOGIN_URL = RealControlIQApi.LOGIN_URL + ciq.login = lambda email, password: RealControlIQApi.login(ciq, email, password) + + with requests_mock.Mocker() as m: + m.get('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f', + request_headers=base_headers(), + + text=self.LOGIN_HTML) + + def post_callback(request, context): + context.status_code = 200 + return '