diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f5e40..a4cb4e1 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', [ + 'https://my-deployed-dash-app.com', + 'http://localhost:8050' +]) +``` + ## [0.0.11] - 2018-02-01 ### Added - Added logging on request failure for the `PlotlyAuth` handler diff --git a/dash_auth/plotly_auth.py b/dash_auth/plotly_auth.py index d887332..0be7627 100644 --- a/dash_auth/plotly_auth.py +++ b/dash_auth/plotly_auth.py @@ -13,6 +13,22 @@ 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. + 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 + """ super(PlotlyAuth, self).__init__(app, app_url) self._fid = create_or_overwrite_dash_app( @@ -98,12 +114,11 @@ 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)) 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() @@ -132,13 +147,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, }) } 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' diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py index dd529c3..4adfbe7 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) @@ -76,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( @@ -91,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 80829c7..6464765 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 @@ -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 @@ -62,6 +61,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 0947fd9..48b47ff 100644 --- a/tests/test_plotly_auth_integration.py +++ b/tests/test_plotly_auth_integration.py @@ -10,13 +10,14 @@ 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 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,50 +36,46 @@ 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) - 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() - 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( - 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( @@ -87,20 +84,18 @@ def private_app_unauthorized(self, url_base_pathname): # 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): + 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) - 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') @@ -116,3 +111,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/' + ] + ) 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(