From 409b708b08c015f259de836c8f833a9777af925f Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 17:06:04 -0400 Subject: [PATCH 1/9] support multiple oauth URLs --- dash_auth/plotly_auth.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/dash_auth/plotly_auth.py b/dash_auth/plotly_auth.py index d887332..d346039 100644 --- a/dash_auth/plotly_auth.py +++ b/dash_auth/plotly_auth.py @@ -13,6 +13,19 @@ class PlotlyAuth(OAuthBase): TOKEN_COOKIE_NAME = 'plotly_oauth_token' def __init__(self, app, app_name, sharing, app_url): + """ + Provides Plotly Authentication login screen to a Dash app. + + Args: + app: A `dash.Dash` app + app_name: The name of your Dash app. This name will be registered + on the Plotly server + sharing: 'private' or 'public' + app_url: String or list of strings. The URL(s) of the Dash app. + This is used to register your app with Plotly's OAuth system. + Returns: + None + """ super(PlotlyAuth, self).__init__(app, app_url) self._fid = create_or_overwrite_dash_app( @@ -98,7 +111,7 @@ def create_or_overwrite_dash_app(filename, sharing, app_url): 'filename': filename, 'share_key_enabled': True if sharing == 'secret' else False, 'world_readable': True if sharing == 'public' else False, - 'app_url': app_url + 'app_url': app_url if isinstance(app_url, str) else app_url[0] }) res_lookup = api_requests.get('/v2/files/lookup?path={}'.format(filename)) @@ -132,13 +145,19 @@ def create_or_overwrite_dash_app(filename, sharing, app_url): def create_or_overwrite_oauth_app(app_url, name): - redirect_uri = '{}/_oauth-redirect'.format(app_url.strip('/')) + if isinstance(app_url, str): + redirect_uris = '{}/_oauth-redirect'.format(app_url.strip('/')) + else: + redirect_uris = ' '.join([ + '{}/_oauth-redirect'.format(url.strip('/')) + for url in app_url + ]) request_data = { 'data': json.dumps({ 'name': name, 'client_type': 'public', 'authorization_grant_type': 'implicit', - 'redirect_uris': redirect_uri, + 'redirect_uris': redirect_uris, }) } From 6de763d3e605f456bb609fd183ddf4db294c45b6 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 17:09:34 -0400 Subject: [PATCH 2/9] add integration test --- tests/test_plotly_auth_integration.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/test_plotly_auth_integration.py b/tests/test_plotly_auth_integration.py index 0947fd9..b87d8a1 100644 --- a/tests/test_plotly_auth_integration.py +++ b/tests/test_plotly_auth_integration.py @@ -16,7 +16,8 @@ class Tests(IntegrationTests): - def plotly_auth_login_flow(self, username, pw, url_base_pathname): + def plotly_auth_login_flow(self, username, pw, + url_base_pathname=None, oauth_urls=None): os.environ['PLOTLY_USERNAME'] = users['creator']['username'] os.environ['PLOTLY_API_KEY'] = users['creator']['api_key'] app = dash.Dash(__name__, url_base_pathname=url_base_pathname) @@ -35,7 +36,10 @@ def update_output(new_value): app, 'integration-test', 'private', - 'http://localhost:8050{}'.format(url_base_pathname) + ( + 'http://localhost:8050{}'.format(url_base_pathname) + if url_base_pathname else oauth_urls + ) ) self.startServer(app) @@ -69,11 +73,12 @@ def update_output(new_value): username, pw, url_base_pathname)) self.wait_for_element_by_css_selector('input[name="allow"]').click() - def private_app_unauthorized(self, url_base_pathname): + def private_app_unauthorized(self, url_base_pathname=None, oauth_urls=None): self.plotly_auth_login_flow( users['viewer']['username'], users['viewer']['pw'], - url_base_pathname + url_base_pathname=url_base_pathname, + oauth_urls=oauth_urls ) time.sleep(5) self.percy_snapshot('private_app_unauthorized 1 - {}'.format( @@ -89,11 +94,11 @@ def private_app_unauthorized(self, url_base_pathname): url_base_pathname)) self.wait_for_element_by_id('dash-auth--login__container') - def private_app_authorized(self, url_base_pathname): + def private_app_authorized(self, url_base_pathname=None, oauth_urls=None): self.plotly_auth_login_flow( users['creator']['username'], users['creator']['pw'], - url_base_pathname + url_base_pathname, ) switch_windows(self.driver) time.sleep(5) @@ -116,3 +121,13 @@ def test_private_app_unauthorized_index(self): def test_private_app_unauthorized_route(self): self.private_app_unauthorized('/my-app/') + + + def test_private_app_authorized_index_multiple_oauth_urls(self): + self.private_app_authorized( + '/', + oauth_urls=[ + 'http://test-domain.plotly.systems:8050/', + 'http://localhost:8050/' + ] + ) From ba7abffa44d8c3d2995881593ec4fd28928928f2 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 17:10:05 -0400 Subject: [PATCH 3/9] v0.1.0 --- CHANGELOG.md | 12 ++++++++++++ dash_auth/version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f5e40..4aca218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2018-03-23 +### Added +- `PlotlyAuth` now supports multiple URLs. Supply a localhost URL and a remote +URL in order to test your Plotly login on your local machine while keeping +the login screen available in your deployed app. Usage: +``` +dash_auth.PlotlyAuth(app, 'my-app', 'private', [ + 'http://localhost:8050', + 'https://my-deployed-dash-app.com' +]) +``` + ## [0.0.11] - 2018-02-01 ### Added - Added logging on request failure for the `PlotlyAuth` handler diff --git a/dash_auth/version.py b/dash_auth/version.py index ad3cf1d..b794fd4 100644 --- a/dash_auth/version.py +++ b/dash_auth/version.py @@ -1 +1 @@ -__version__ = '0.0.11' +__version__ = '0.1.0' From c21e5101f2a4a08887296093153b9ea98cdcaf97 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 19:05:38 -0400 Subject: [PATCH 4/9] simplify `wait_for` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the previous implementation of `wait_for` wasn’t very reliable. I switched it out in a separate dash repo in the fall, now copying those changes over here. --- tests/IntegrationTests.py | 38 ++++++++++++++++----------- tests/utils.py | 55 --------------------------------------- 2 files changed, 23 insertions(+), 70 deletions(-) diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py index dd529c3..27e10c7 100644 --- a/tests/IntegrationTests.py +++ b/tests/IntegrationTests.py @@ -11,10 +11,32 @@ import sys import os -from .utils import assert_clean_console, invincible, switch_windows, wait_for +from .utils import assert_clean_console, switch_windows +TIMEOUT = 60 class IntegrationTests(unittest.TestCase): + def wait_for_element_by_css_selector(self, selector): + start_time = time.time() + while time.time() < start_time + TIMEOUT: + try: + return self.driver.find_element_by_css_selector(selector) + except Exception as e: + pass + time.sleep(0.25) + raise e + + def wait_for_text_to_equal(self, selector, assertion_text): + start_time = time.time() + while time.time() < start_time + TIMEOUT: + el = self.wait_for_element_by_css_selector(selector) + try: + return self.assertEqual(el.text, assertion_text) + except Exception as e: + pass + time.sleep(0.25) + raise e + def percy_snapshot(cls, name): if ('PERCY_PROJECT' in os.environ and os.environ['PERCY_PROJECT'] == 'plotly/dash-auth'): @@ -55,20 +77,6 @@ def setUp(self): super(IntegrationTests, self).setUp() self.driver = webdriver.Chrome() - def wait_for_element_by_id(id): - wait_for(lambda: None is not invincible( - lambda: self.driver.find_element_by_id(id) - )) - return self.driver.find_element_by_id(id) - self.wait_for_element_by_id = wait_for_element_by_id - - def wait_for_element_by_css_selector(css_selector): - wait_for(lambda: None is not invincible( - lambda: self.driver.find_element_by_css_selector(css_selector) - )) - return self.driver.find_element_by_css_selector(css_selector) - self.wait_for_element_by_css_selector = wait_for_element_by_css_selector - def tearDown(self): super(IntegrationTests, self).tearDown() time.sleep(5) diff --git a/tests/utils.py b/tests/utils.py index e0eb16c..496708e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,9 +4,6 @@ import time -TIMEOUT = 20 # Seconds - - def clean_history(driver, domains): temp = driver.get_location() for domain in domains: @@ -15,15 +12,6 @@ def clean_history(driver, domains): driver.open(temp) -def invincible(func): - def wrap(): - try: - return func() - except: - pass - return wrap - - def switch_windows(driver): new_window_handle = None while not new_window_handle: @@ -39,49 +27,6 @@ class WaitForTimeout(Exception): pass -def wait_for(condition_function, get_message=lambda: '', *args, **kwargs): - """ - Waits for condition_function to return True or raises WaitForTimeout. - :param (function) condition_function: Should return True on success. - :param args: Optional args to pass to condition_function. - :param kwargs: Optional kwargs to pass to condition_function. - if `timeout` is in kwargs, it will be used to override TIMEOUT - :raises: WaitForTimeout If condition_function doesn't return True in time. - Usage: - def get_element(selector): - # some code to get some element or return a `False`-y value. - selector = '.js-plotly-plot' - try: - wait_for(get_element, selector) - except WaitForTimeout: - self.fail('element never appeared...') - plot = get_element(selector) # we know it exists. - """ - def wrapped_condition_function(): - """We wrap this to alter the call base on the closure.""" - if args and kwargs: - return condition_function(*args, **kwargs) - if args: - return condition_function(*args) - if kwargs: - return condition_function(**kwargs) - return condition_function() - - if 'timeout' in kwargs: - timeout = kwargs['timeout'] - del kwargs['timeout'] - else: - timeout = TIMEOUT - - start_time = time.time() - while time.time() < start_time + timeout: - if wrapped_condition_function(): - return True - time.sleep(0.5) - - raise WaitForTimeout(get_message()) - - def assert_clean_console(TestClass): def assert_no_console_errors(TestClass): TestClass.assertEqual( From 683438efa1a9d0defe6007ebeb8ff484b6646bb4 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 19:06:42 -0400 Subject: [PATCH 5/9] switch to new version of `wait_for` --- tests/test_basic_auth_integration.py | 7 +++--- tests/test_plotly_auth_integration.py | 34 ++++++++++++--------------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 80829c7..f17a9ad 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -14,7 +14,7 @@ from selenium.common.exceptions import NoSuchElementException from .IntegrationTests import IntegrationTests -from .utils import assert_clean_console, invincible, switch_windows, wait_for +from .utils import assert_clean_console, switch_windows from dash_auth import basic_auth @@ -62,6 +62,5 @@ def update_output(new_value): # but it saves the credentials as part of the browser. # visiting the page again will use the saved credentials self.driver.get('http://localhost:8050') - time.sleep(5) - el = self.wait_for_element_by_id('output') - self.assertEqual(el.text, 'initial value') + el = self.wait_for_element_by_css_selector('#output') + self.wait_for_text_to_equal('#output', 'initial value') diff --git a/tests/test_plotly_auth_integration.py b/tests/test_plotly_auth_integration.py index b87d8a1..b563cbc 100644 --- a/tests/test_plotly_auth_integration.py +++ b/tests/test_plotly_auth_integration.py @@ -10,7 +10,7 @@ import plotly.plotly as py from .IntegrationTests import IntegrationTests -from .utils import assert_clean_console, invincible, switch_windows, wait_for +from .utils import assert_clean_console, switch_windows from .users import users from dash_auth import plotly_auth @@ -44,31 +44,27 @@ def update_output(new_value): self.startServer(app) - time.sleep(10) - self.percy_snapshot('login screen - {} {} {}'.format( - username, pw, url_base_pathname)) try: - el = self.wait_for_element_by_id('dash-auth--login__container') + el = self.wait_for_element_by_css_selector('#dash-auth--login__container') except Exception as e: print(self.wait_for_element_by_tag_name('body').html) raise e - self.driver.find_element_by_id('dash-auth--login__button').click() - time.sleep(5) + self.wait_for_element_by_css_selector( + '#dash-auth--login__button').click() switch_windows(self.driver) - time.sleep(20) - self.wait_for_element_by_id( - 'js-auth-modal-signin-username' + self.wait_for_element_by_css_selector( + '#js-auth-modal-signin-username' ).send_keys(username) - self.driver.find_element_by_id( - 'js-auth-modal-signin-password' + self.wait_for_element_by_css_selector( + '#js-auth-modal-signin-password' ).send_keys(pw) - self.driver.find_element_by_id('js-auth-modal-signin-submit').click() + self.wait_for_element_by_css_selector( + '#js-auth-modal-signin-submit').click() # wait for oauth screen - time.sleep(5) self.percy_snapshot('oauth screen - {} {} {}'.format( username, pw, url_base_pathname)) self.wait_for_element_by_css_selector('input[name="allow"]').click() @@ -80,10 +76,10 @@ def private_app_unauthorized(self, url_base_pathname=None, oauth_urls=None): url_base_pathname=url_base_pathname, oauth_urls=oauth_urls ) - time.sleep(5) self.percy_snapshot('private_app_unauthorized 1 - {}'.format( url_base_pathname)) - el = self.wait_for_element_by_id('dash-auth--authorization__denied') + el = self.wait_for_element_by_css_selector( + '#dash-auth--authorization__denied') self.assertEqual(el.text, 'You are not authorized to view this app') switch_windows(self.driver) self.percy_snapshot('private_app_unauthorized 2 - {}'.format( @@ -92,7 +88,8 @@ def private_app_unauthorized(self, url_base_pathname=None, oauth_urls=None): # login screen should still be there self.percy_snapshot('private_app_unauthorized 3 - {}'.format( url_base_pathname)) - self.wait_for_element_by_id('dash-auth--login__container') + self.wait_for_element_by_css_selector( + '#dash-auth--login__container') def private_app_authorized(self, url_base_pathname=None, oauth_urls=None): self.plotly_auth_login_flow( @@ -101,11 +98,10 @@ def private_app_authorized(self, url_base_pathname=None, oauth_urls=None): url_base_pathname, ) switch_windows(self.driver) - time.sleep(5) self.percy_snapshot('private_app_authorized - {}'.format( url_base_pathname)) try: - el = self.wait_for_element_by_id('output') + el = self.wait_for_element_by_css_selector('#output') except: print((self.driver.find_element_by_tag_name('body').html)) self.assertEqual(el.text, 'initial value') From 59a7206650614a9495388c6a7609994cffa28144 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 19:07:15 -0400 Subject: [PATCH 6/9] remove percy snapshots they are misconfigured for some reason and not super necessary for this test suite anyway --- tests/test_plotly_auth_integration.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_plotly_auth_integration.py b/tests/test_plotly_auth_integration.py index b563cbc..48b47ff 100644 --- a/tests/test_plotly_auth_integration.py +++ b/tests/test_plotly_auth_integration.py @@ -65,8 +65,6 @@ def update_output(new_value): '#js-auth-modal-signin-submit').click() # wait for oauth screen - self.percy_snapshot('oauth screen - {} {} {}'.format( - username, pw, url_base_pathname)) self.wait_for_element_by_css_selector('input[name="allow"]').click() def private_app_unauthorized(self, url_base_pathname=None, oauth_urls=None): @@ -76,8 +74,6 @@ def private_app_unauthorized(self, url_base_pathname=None, oauth_urls=None): url_base_pathname=url_base_pathname, oauth_urls=oauth_urls ) - self.percy_snapshot('private_app_unauthorized 1 - {}'.format( - url_base_pathname)) el = self.wait_for_element_by_css_selector( '#dash-auth--authorization__denied') self.assertEqual(el.text, 'You are not authorized to view this app') @@ -98,8 +94,6 @@ def private_app_authorized(self, url_base_pathname=None, oauth_urls=None): url_base_pathname, ) switch_windows(self.driver) - self.percy_snapshot('private_app_authorized - {}'.format( - url_base_pathname)) try: el = self.wait_for_element_by_css_selector('#output') except: From 451a88abb7049966a956f5e2b10405f8d7cbfa06 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Fri, 23 Mar 2018 19:09:07 -0400 Subject: [PATCH 7/9] fix tests for newest chromedriver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `self.driver.get(…)` is now blocking if the page has a basic auth screen and it takes a long time to error out. this wasn’t the case with the old chromedriver. --- tests/IntegrationTests.py | 11 ++--------- tests/test_basic_auth_integration.py | 5 ++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py index 27e10c7..4adfbe7 100644 --- a/tests/IntegrationTests.py +++ b/tests/IntegrationTests.py @@ -84,7 +84,7 @@ def tearDown(self): time.sleep(5) self.driver.quit() - def startServer(self, app): + def startServer(self, app, skip_visit=False): def run(): app.scripts.config.serve_locally = True app.run_server( @@ -99,17 +99,10 @@ def run(): time.sleep(15) # Visit the dash page - try: + if not skip_visit: self.driver.get('http://localhost:8050{}'.format( app.config['routes_pathname_prefix']) ) - except: - print('Failed attempt to load page, trying again') - print(self.server_process) - print(self.server_process.is_alive()) - time.sleep(5) - print(requests.get('http://localhost:8050')) - self.driver.get('http://localhost:8050') time.sleep(0.5) diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index f17a9ad..6464765 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -46,13 +46,12 @@ def update_output(new_value): TEST_USERS['valid'] ) - self.startServer(app) + self.startServer(app, skip_visit=True) + self.assertEqual( requests.get('http://localhost:8050').status_code, 401 ) - with self.assertRaises(NoSuchElementException): - self.driver.find_element_by_id('output') # login using the URL instead of the alert popup # selenium has no way of accessing the alert popup From 35c8ee6d4992178f6899a2fa31f58ad10a3855ce Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 12:25:44 -0400 Subject: [PATCH 8/9] consistent URL order --- CHANGELOG.md | 4 ++-- dash_auth/plotly_auth.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aca218..a4cb4e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ URL in order to test your Plotly login on your local machine while keeping the login screen available in your deployed app. Usage: ``` dash_auth.PlotlyAuth(app, 'my-app', 'private', [ - 'http://localhost:8050', - 'https://my-deployed-dash-app.com' + 'https://my-deployed-dash-app.com', + 'http://localhost:8050' ]) ``` diff --git a/dash_auth/plotly_auth.py b/dash_auth/plotly_auth.py index d346039..dc2f1d5 100644 --- a/dash_auth/plotly_auth.py +++ b/dash_auth/plotly_auth.py @@ -23,6 +23,9 @@ def __init__(self, app, app_name, sharing, app_url): sharing: 'private' or 'public' app_url: String or list of strings. The URL(s) of the Dash app. This is used to register your app with Plotly's OAuth system. + For example, to test locally, supply a list of URLs with + the first URL being your remote server and the second URL + being e.g. http://localhost:8050 Returns: None """ From 7d8f8a070232405e46472e9a03cd176793418841 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 12:25:57 -0400 Subject: [PATCH 9/9] rm TODO not applicable anymore --- dash_auth/plotly_auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dash_auth/plotly_auth.py b/dash_auth/plotly_auth.py index dc2f1d5..0be7627 100644 --- a/dash_auth/plotly_auth.py +++ b/dash_auth/plotly_auth.py @@ -119,7 +119,6 @@ def create_or_overwrite_dash_app(filename, sharing, app_url): res_lookup = api_requests.get('/v2/files/lookup?path={}'.format(filename)) if res_lookup.status_code == 404: - # TODO - Better request handling res_create = api_requests.post('/v2/dash-apps', data=payload) try: res_create.raise_for_status()