diff --git a/Pipfile b/Pipfile index d066da1..ce4405c 100644 --- a/Pipfile +++ b/Pipfile @@ -11,7 +11,8 @@ bs4 = "*" arrow = "*" lxml = "*" python-dotenv = "*" +requests-mock = "*" [scripts] tconnectsync = "python3 main.py" -test = "python3 -m unittest discover -vv" \ No newline at end of file +test = "python3 -m unittest discover -vv" diff --git a/Pipfile.lock b/Pipfile.lock index cd40190..63febae 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "071afba23de7f532f99a0611caed63951c6f7ba968ba877ad5c76c244e78577e" + "sha256": "979feca3aba1b7f43890a94862347738158af5847959c14dc1d5f23ce5db8cdc" }, "pipfile-spec": 6, "requires": {}, @@ -126,6 +126,14 @@ "index": "pypi", "version": "==2.25.1" }, + "requests-mock": { + "hashes": [ + "sha256:11215c6f4df72702aa357f205cf1e537cffd7392b3e787b58239bde5fb3db53b", + "sha256:e68f46844e4cee9d447150343c9ae875f99fa8037c6dcf5f15bf1fe9ab43d226" + ], + "index": "pypi", + "version": "==1.8.0" + }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", diff --git a/tconnectsync/api/common.py b/tconnectsync/api/common.py index 6007ada..a9b4ada 100644 --- a/tconnectsync/api/common.py +++ b/tconnectsync/api/common.py @@ -11,7 +11,7 @@ def base_headers(): class ApiException(Exception): def __init__(self, status_code, text, *args, **kwargs): self.status_code = status_code - super().__init__(text, *args, **kwargs) + super().__init__('%s (HTTP %s)' % (text, status_code), *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 ff16833..0e1146b 100644 --- a/tconnectsync/api/controliq.py +++ b/tconnectsync/api/controliq.py @@ -25,18 +25,8 @@ class ControlIQApi: with requests.Session() as s: initial = s.get(self.LOGIN_URL, headers=base_headers()) soup = BeautifulSoup(initial.content, features='lxml') - data = { - "__LASTFOCUS": "", - "__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin", - "__EVENTARGUMENT": "", - "__VIEWSTATE": soup.select_one("#__VIEWSTATE")["value"], - "__VIEWSTATEGENERATOR": soup.select_one("#__VIEWSTATEGENERATOR")["value"], - "__EVENTVALIDATION": soup.select_one("#__EVENTVALIDATION")["value"], - "ctl00$ContentBody$LoginControl$txtLoginEmailAddress": email, - "txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (email, email, email), - "ctl00$ContentBody$LoginControl$txtLoginPassword": password, - "txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password) - } + 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) if req.status_code != 302: raise ApiLoginException(req.status_code, 'Error logging in to t:connect. Check your login credentials.') @@ -51,6 +41,20 @@ class ControlIQApi: self.accessTokenExpiresAt = req.cookies['accessTokenExpiresAt'] return True + def _build_login_data(self, email, password, soup): + return { + "__LASTFOCUS": "", + "__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin", + "__EVENTARGUMENT": "", + "__VIEWSTATE": soup.select_one("#__VIEWSTATE")["value"], + "__VIEWSTATEGENERATOR": soup.select_one("#__VIEWSTATEGENERATOR")["value"], + "__EVENTVALIDATION": soup.select_one("#__EVENTVALIDATION")["value"], + "ctl00$ContentBody$LoginControl$txtLoginEmailAddress": email, + "txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (email, email, email), + "ctl00$ContentBody$LoginControl$txtLoginPassword": password, + "txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % (password, password, password) + } + def needs_relogin(self): diff = (arrow.get(self.accessTokenExpiresAt) - arrow.get()) return (diff.seconds <= 5 * 60) diff --git a/tests/api/test_android.py b/tests/api/test_android.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_controliq.py b/tests/api/test_controliq.py index 0597669..fee38c9 100644 --- a/tests/api/test_controliq.py +++ b/tests/api/test_controliq.py @@ -3,12 +3,120 @@ import unittest import itertools import datetime +import json +import requests_mock + +from bs4 import BeautifulSoup from .fake import ControlIQApi -from tconnectsync.api.common import ApiException +from tconnectsync.api.controliq import ControlIQApi as RealControlIQApi +from tconnectsync.api.common import ApiException, ApiLoginException, base_headers class TestControlIQApi(unittest.TestCase): + LOGIN_HTML = """ + + +
+
+ + + + +
+
+ + +
+
+ + + """ + + LOGIN_POST_DATA = { + "__LASTFOCUS": "", + "__EVENTTARGET": "ctl00$ContentBody$LoginControl$linkLogin", + "__EVENTARGUMENT": "", + "__VIEWSTATE": "AAAAA", + "__VIEWSTATEGENERATOR": "BBBBB", + "__EVENTVALIDATION": "CCCCC", + "ctl00$ContentBody$LoginControl$txtLoginEmailAddress": "email@email.com", + "txtLoginEmailAddress_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % ("email@email.com", "email@email.com", "email@email.com"), + "ctl00$ContentBody$LoginControl$txtLoginPassword": "password", + "txtLoginPassword_ClientState": '{"enabled":true,"emptyMessage":"","validationText":"%s","valueAsString":"%s","lastSetTextBoxValue":"%s"}' % ("password", "password", "password") + } + + def test_build_login_data(self): + ciq = ControlIQApi() + soup = BeautifulSoup(self.LOGIN_HTML, features='lxml') + + self.assertDictEqual( + ciq._build_login_data('email@email.com', 'password', soup), + self.LOGIN_POST_DATA) + + def test_login_successful(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 = 302 + context.headers['Location'] = '/newlocation' + context.cookies['UserGUID'] = 'user_guid' + context.cookies['accessToken'] = 'access_tok' + context.cookies['accessTokenExpiresAt'] = 'access_tok_expire' + return '' + + m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f', + request_headers={'Referer': ciq.LOGIN_URL, **base_headers()}, + + text=post_callback) + + m.post('https://tconnect.tandemdiabetes.com/newlocation', + cookies={'cookie': 'value'}, + headers=base_headers(), + + status_code=200) + + self.assertTrue(ciq.login('email@email.com', 'password')) + + self.assertEqual(ciq.userGuid, 'user_guid') + self.assertEqual(ciq.accessToken, 'access_tok') + self.assertEqual(ciq.accessTokenExpiresAt, 'access_tok_expire') + + + def test_login_invalid_credentials(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 '...' + + m.post('https://tconnect.tandemdiabetes.com/login.aspx?ReturnUrl=%2f', + request_headers={'Referer': ciq.LOGIN_URL, **base_headers()}, + + text=post_callback) + + self.assertRaises(ApiLoginException, ciq.login, 'email@email.com', 'password') + + self.assertIsNone(ciq.userGuid) + self.assertIsNone(ciq.accessToken) + self.assertIsNone(ciq.accessTokenExpiresAt) + def fake_get_with_http_code(self, http_code, expected_endpoint, num_times): tries = 0 def fake_get(endpoint, query): @@ -42,7 +150,7 @@ class TestControlIQApi(unittest.TestCase): ciq._get = self.fake_get_with_http_code(500, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 2) - ex = self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02') + self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02') def test_therapy_timeline_triggers_relogin_after_single_http_401(self): ciq = ControlIQApi() @@ -84,7 +192,7 @@ class TestControlIQApi(unittest.TestCase): ciq._get = self.fake_get_with_http_code(401, "therapytimeline/users/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 2) - ex = self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02') + self.assertRaises(ApiException, ciq.therapy_timeline, '2021-04-01', '2021-04-02') self.assertListEqual(hit_login, [ ('email', 'password')