From 016ac3b753cdf17c7a66711b8ff0c669c919ffbf Mon Sep 17 00:00:00 2001 From: "Karl O. Pinc" Date: Mon, 30 Nov 2020 20:55:01 -0600 Subject: [PATCH] Add home_page and menu_page concepts --- README.rst | 86 ++++++- examples/etc/pgwui.ini | 87 ++++++- examples/misc/development.ini | 89 +++++++- src/pgwui_server/checkset.py | 142 +++++++++++- src/pgwui_server/exceptions.py | 7 + src/pgwui_server/pgwui_server.py | 76 +++++-- tests/test_checkset.py | 378 ++++++++++++++++++++++++++++++- tests/test_pgwui_server.py | 113 +++++++-- 8 files changed, 906 insertions(+), 72 deletions(-) diff --git a/README.rst b/README.rst index 4076584..35bca56 100644 --- a/README.rst +++ b/README.rst @@ -221,6 +221,66 @@ Manual specification, with or without autoconfiguration, can be convenient when writing your own components. (It also eliminates the trivial overhead involved in autoconfiguration.) +Configuring Navbar Links +^^^^^^^^^^^^^^^^^^^^^^^^ + +Navbar link configuration is optional. PGWUI comes with sensible +defaults. + + pgwui.home_page:: How to link to the site's home page. + type: URL (default), asset, file + + URL + source: + + The default is ``/``, when there is no home_page setting. + Which produces an URL with no "path". + + * A URI path beginning with ``/``. E.g.: '/home' + + * An URL without a protocol, so an URL beginning with ``//`` and + followed by a domain. E.g.: //www.example.com The URL + delivered to the browser contains the protocol used in the request. + + * An URL with a protocol. E.g.: https://www.example.com + + file: + source: + A fully-qualified file system path, so a path beginning with + a ``/``. E.g. /var/www/html/index.html + Served with a content encoding of ``text/html``. + + asset: + source: + * A `Pyramid`_ `asset specification`_. It must reference a + `static asset`_, a file included in a `Pyramid`_ application. + Typically file containing a page of HTML. + + This is only useful to users who write their own `Pyramid`_ + applications that are either PGWUI modules or incorporate + PGWUI. + + route: + source: + * A `Pyramid`_ `route name`_. Used to reach a page generated + by a `Pyramid`_ application which uses `URL dispatch`_. + + This is only useful to users who write their own `Pyramid`_ + applications that incorporate PGWUI. + + pgwui.menu_page:: How to link to a menu of PGWUI components. + All of the "type"s of ``pgwui.home_page`` are available. + + +Configuration Settings Common to All PGWUI Components +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PGWUI modules all have the following configuration settings: + + menu_label + The label for PGWUI_Menu to display, when different from the default + + Configuring Routing ^^^^^^^^^^^^^^^^^^^ @@ -272,21 +332,26 @@ HTML templates are used to generate the page's HTML. Templates control what is displayed. Here is a list of the current asset specifications with brief -descriptions:: +descriptions: - pgwui_common:static/pgwui.css The CSS file for PGWUI. + pgwui_common:static/pgwui.css + The CSS file for PGWUI. - pgwui_common:templates/base.mak Common "background" items on all pages. + pgwui_common:templates/base.mak + Common "background" items on all pages. - pgwui_common:templates/auth_base.mak Common "background" items on all - pages requesting database connection and login information. + pgwui_common:templates/auth_base.mak + Common "background" items on all + pages requesting database connection and login information. - pgwui_logout:templates/logout.mak The logout page. + pgwui_logout:templates/logout.mak + The logout page. - pgwui_upload:templates/upload.mak The upload page. + pgwui_upload:templates/upload.mak + The upload page. Assets can be overridden in the configuration file with -``pgwui.override_asset``: +``pgwui.override_asset``:: pgwui.override_asset = # Syntax is: asset_to_override = override_with @@ -530,6 +595,11 @@ provided by `The Dian Fossey Gorilla Fund .. _WSGI: https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface .. _pip: https://pip.pypa.io/en/stable/ +.. _asset specification: https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/assets.html#understanding-asset-specifications +.. _static asset: https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/assets.html#serving-static-assets +.. _route name: https://docs.pylonsproject.org/projects/pyramid/en/1.10-branch/narr/urldispatch.html#route-configuration +.. _URL dispatch: https://docs.pylonsproject.org/projects/pyramid/en/1.10-branch/narr/urldispatch.html + .. rubric:: Footnotes diff --git a/examples/etc/pgwui.ini b/examples/etc/pgwui.ini index 969a4dc..1eaa1eb 100644 --- a/examples/etc/pgwui.ini +++ b/examples/etc/pgwui.ini @@ -17,7 +17,7 @@ use = egg:PGWUI_Server # PGWUI configuration # -# Postgres client configuration. +# Postgres connection configuration. # Both pgwui.pg_host and pgwui.pg_port are optional; # they default to the Unix PG socket and the default Postgres port. @@ -28,6 +28,84 @@ pgwui.pg_port = 5432 # There are occasions when PGWUI uses a default database. (optional) pgwui.default_db = template1 + +# How to link to the site's home page. +# +# This is a multi-valued setting. The keys depend on the "type" +# used. +# +# type: +# URL An URL. This is the default. +# file A file containing HTML, read from the file system. +# asset A Pyramid asset specification. Results in an URL +# which references a file which is part of a PGWUI +# component, or some other "static" file included in a Pyramid +# application. +# route A Pyramid route name. Results in an URL +# which references a page generated by a Pyramid application. +# "pgwui.route_prefix" is not applied. +# +# +# When "type" is "URL, there are the following keys: +# +# source: (required) +# +# The default is ``/``, when there is no home_page setting. +# Which produces an URL with no "path". +# +# * A URI path beginning with ``/``. E.g.: '/home' +# +# * An URL without a protocol, so an URL beginning with ``//`` and +# followed by a domain. E.g.: //www.example.com The URL +# delivered to the browser contains the protocol used in the request. +# +# * An URL with a protocol. E.g.: https://www.example.com +# +# +# When "type" is "file", there are the following keys +# +# source: (required) +# A fully-qualified file system path, so a path beginning with +# a ``/``. E.g. /var/www/html/index.html +# Served with a content encoding of ``text/html``. +# +# url_path: (required) +# The "path" component of the URL used to retrieve the file. +# Must begin with a ``/``. "pgwui.route_prefix" is not applied. +# +# When type is "asset", there are the following keys: +# +# source: (required) +# +# * A `Pyramid`_ `asset specification`_. It must reference a +# `static asset`_, a file included in a `Pyramid`_ application. +# A file containing a page of HTML. +# +# +# When type is "route", there are the following keys: +# +# source: (required) +# +# * A `Pyramid`_ `route name`_. Used to reach a page generated +# by a `Pyramid`_ application which uses `URL dispatch`_. +# +# +# pgwui.home_page = +# type = URL +# source = / + +# How to link to a menu of PGWUI components. An alternative to using +# the PGWUI_Menu component. +# Configured as pgwui.home_page is configured, above. +# The default is to have no menu. The following uses the URL +# without a path (e.g., http://www.example.com/) as the menu page. +# +# pgwui.menu_page = +# type = URL +# source = / +# +# pgwui.menu_page overrides what is provided by the PGWUI_Menu component. + # Whether to auto-discover the pgwui component modules. (optional) # When False pgwui component names must be listed in pyramid.includes=... # pgwui.autoconfigure = True @@ -43,7 +121,7 @@ pgwui.dry_run = False # Routing -# Routes are what call up specific pages. They are the +# Routes are what call up specific pages. They are usually the # part of the URL which comes after the http://example.com. # # All routes should probably begin with a "/" character but @@ -71,8 +149,8 @@ pgwui.dry_run = False # # The default for some PGWUI components are: # pgwui.routes = -# pgwui_logout = /logmeout -# pgwui_upload = /put-in +# pgwui_logout = /logout +# pgwui_upload = /upload # Settings validation @@ -81,6 +159,7 @@ pgwui.dry_run = False # vulnerabilties. Validation is on by default. # pgwui.validate_hmac = True + # PGWUI Component Settings # Menu presentation diff --git a/examples/misc/development.ini b/examples/misc/development.ini index 0d069bd..9ea841f 100644 --- a/examples/misc/development.ini +++ b/examples/misc/development.ini @@ -17,7 +17,7 @@ use = egg:PGWUI_Server # PGWUI configuration # -# Postgres client configuration. +# Postgres connection configuration. # Both pgwui.pg_host and pgwui.pg_port are optional; # they default to the Unix PG socket and the default Postgres port. @@ -29,12 +29,95 @@ pgwui.pg_port = 5432 pgwui.default_db = template1 +# How to link to the site's home page. Useful when the home page is +# not the PGWUI menu. +# +# This is a multi-valued setting. The keys depend on the "type" +# used. +# +# type: +# URL An URL. This is the default. +# file A file containing HTML, read from the file system. +# asset A Pyramid asset specification. Results in an URL +# which references a file which is part of a PGWUI +# component, or some other "static" file included in a Pyramid +# application. +# route A Pyramid route name. Results in an URL +# which references a page generated by a Pyramid application. +# "pgwui.route_prefix" is not applied. +# +# +# When "type" is "URL, there are the following keys: +# +# source: (required) +# +# The default is ``/``, when there is no home_page setting. +# Which produces an URL with no "path". +# +# * A URI path beginning with ``/``. E.g.: '/home' +# +# * An URL without a protocol, so an URL beginning with ``//`` and +# followed by a domain. E.g.: //www.example.com The URL +# delivered to the browser contains the protocol used in the request. +# +# * An URL with a protocol. E.g.: https://www.example.com +# +# +# When "type" is "file", there are the following keys +# +# source: (required) +# A fully-qualified file system path, so a path beginning with +# a ``/``. E.g. /var/www/html/index.html +# Served with a content encoding of ``text/html``. +# +# url_path: (required) +# The "path" component of the URL used to retrieve the file. +# Must begin with a ``/``. "pgwui.route_prefix" is not applied. +# +# When type is "asset", there are the following keys: +# +# source: (required) +# +# * A `Pyramid`_ `asset specification`_. It must reference a +# `static asset`_, a file included in a `Pyramid`_ application. +# A file containing a page of HTML. +# +# +# When type is "route", there are the following keys: +# +# source: (required) +# +# * A `Pyramid`_ `route name`_. Used to reach a page generated +# by a `Pyramid`_ application which uses `URL dispatch`_. +# +# +# pgwui.home_page = +# type = URL +# source = / + + +# How to link to a menu of PGWUI components. An alternative to using +# the PGWUI_Menu component. +# Configured as pgwui.home_page is configured, above. +# The default is to have no menu. The following uses the URL +# without a path (e.g., http://www.example.com/) as the menu page. +# +# pgwui.menu_page = +# type = URL +# source = / +# +# pgwui.menu_page overrides what is provided by the PGWUI_Menu component. + # Whether to auto-discover the pgwui component modules. (optional) # When False pgwui component names must be listed in pyramid.includes=... # pgwui.autoconfigure = True # What PGWUI components and other pyramid modules to use. -# (Required when pgwui.autoconfigure is False.) +# (Required when pgwui.autoconfigure is False, or you want the +# debug toolbar.) +#pyramid.includes = +# pgwui_logout +# pgwui_upload pyramid.includes = pyramid_debugtoolbar @@ -81,6 +164,7 @@ pgwui.dry_run = False # vulnerabilties. Validation is on by default. pgwui.validate_hmac = False + # PGWUI Component Settings # Menu presentation @@ -150,7 +234,6 @@ session.key = pgwui_server # HMAC secret #session.secret = xxxxxxrandomstring40characterslongxxxxxx # Send cookie only over https -# (True for production) # WARNING: To use HTTP, not HTTPS, session.secure must be False! # CAUTION: If you are forcing the browser to use HTTPS you want # session.secure to be True. diff --git a/src/pgwui_server/checkset.py b/src/pgwui_server/checkset.py index 62746ba..c1fc14e 100644 --- a/src/pgwui_server/checkset.py +++ b/src/pgwui_server/checkset.py @@ -20,25 +20,33 @@ # Karl O. Pinc -'''Check the pgwui settings (in the internal "dict format") +'''Validate PGWUI_Core and PGWUI_Common configuration ''' +import re from ast import literal_eval from . import constants from pgwui_common import exceptions as common_ex +from pgwui_common import checkset import pgwui_server.exceptions as server_ex +# Regular expressions for page "source" values, by type +URL_RE = re.compile('^(?:(?:[^:/]+:)?//[^/])|(?:/(?:[^/]|$))') + + def key_to_ini(key): '''Convert the setting key to a key used in an ini file's declaration ''' - return 'pgwui.{}'.format(key) + return 'pgwui:{}'.format(key) -def require_setting(errors, setting, pgwui_settings): +def require_setting(errors, setting, pgwui_settings, formatter): if setting not in pgwui_settings: - errors.append(common_ex.MissingSettingError(key_to_ini(setting))) + errors.append(common_ex.MissingSettingError(formatter(setting))) + return False + return True def boolean_setting(errors, setting, pgwui_settings): @@ -65,7 +73,7 @@ def validate_setting_values(errors, settings): # default_db can be missing, then the user sees no default # dry_run - require_setting(errors, 'dry_run', pgwui_settings) + require_setting(errors, 'dry_run', pgwui_settings, key_to_ini) boolean_setting(errors, 'dry_run', pgwui_settings) # route_prefix can be missing, defaults to no route prefix which is fine. @@ -96,3 +104,127 @@ def validate_hmac(errors, settings): if len(settings['session.secret']) != constants.HMAC_LEN: errors.append(server_ex.HMACLengthError()) return + + +def page_key_to_ini(page_key, subkey): + '''Convert the page setting subkey to a ini file declaration + ''' + return key_to_ini(f'{page_key}:{subkey}') + + +def require_page_settings(errors, required_settings, page_settings, page_key): + '''Check for required keys in the page setting + ''' + def subkey_to_ini(subkey): + return page_key_to_ini(page_key, subkey) + + have_settings = True + for subkey in required_settings: + have_settings &= require_setting( + errors, subkey, page_settings, subkey_to_ini) + + return have_settings + + +def validate_url_source(errors, page_key, source): + '''Validate the page setting "source" for URLs + ''' + if URL_RE.match(source): + return + errors.append(common_ex.BadURLSourceError( + page_key_to_ini(page_key, 'source'), source)) + + +def validate_url_path(errors, page_key, page_settings): + '''Validate the page setting "url_path" + ''' + url_path = page_settings['url_path'] + if url_path[0:1] == '/': + return + errors.append(common_ex.BadFileURLPathError( + page_key_to_ini(page_key, 'url_path'), url_path)) + + +def validate_file_source(errors, page_key, source): + '''Validate the page setting "source" for files + ''' + if source[0:1] == '/': + return + errors.append(common_ex.BadFileSourceError( + page_key_to_ini(page_key, 'file'), source)) + + +def validate_route_source(errors, page_key, source): + '''Validate the page setting "source" for routes + + The routes are not yet established, so we don't confirm + existance at this point. + ''' + if source != '': + return + errors.append(common_ex.BadRouteSourceError( + page_key_to_ini(page_key, 'route'), source)) + + +def validate_asset_source(errors, page_key, source): + '''Validate the page setting "source" for assets + ''' + if source != '': + return + errors.append(common_ex.BadAssetSourceError( + page_key_to_ini(page_key, 'asset'), source)) + + +def validate_file_content(errors, page_key, page_settings, source): + '''Validate the content of a "file" page setting + ''' + validate_file_source(errors, page_key, source) + if require_page_settings( + errors, ['url_path'], page_settings, page_key): + validate_url_path(errors, page_key, page_settings) + errors.extend(checkset.unknown_settings( + f'pgwui:{page_key}', ['type', 'source', 'url_path'], page_settings)) + + +def validate_type_content(errors, page_key, page_settings): + '''Validate the page setting's "type", and other page setting content + based on the type + ''' + type = page_settings['type'] + source = page_settings['source'] + if type == 'URL': + validate_url_source(errors, page_key, source) + errors.extend(checkset.unknown_settings( + 'pgwui_common', ['type', 'source'], page_settings)) + return + if type == 'file': + validate_file_content(errors, page_key, page_settings, source) + return + if type == 'route': + validate_route_source(errors, page_key, source) + errors.extend(checkset.unknown_settings( + 'pgwui_common', ['type', 'source'], page_settings)) + return + if type == 'asset': + validate_asset_source(errors, page_key, source) + errors.extend(checkset.unknown_settings( + 'pgwui_common', ['type', 'source'], page_settings)) + return + + errors.append(common_ex.BadPageTypeError( + page_key_to_ini(page_key, 'type'), type)) + + +def validate_page_setting(errors, settings, page_key): + '''Validate the multiple values of the page setting + ''' + pgwui_settings = settings['pgwui'] + if page_key not in pgwui_settings: + return + + page_settings = pgwui_settings[page_key] + if not require_page_settings( + errors, ['type', 'source'], page_settings, page_key): + return + + validate_type_content(errors, page_key, page_settings) diff --git a/src/pgwui_server/exceptions.py b/src/pgwui_server/exceptions.py index 518ac0f..a2c3ea7 100644 --- a/src/pgwui_server/exceptions.py +++ b/src/pgwui_server/exceptions.py @@ -36,6 +36,13 @@ class AutoconfigureConflict(ServerError): 'Autoconfigure is True and there is a pyramid.include setting') +class MenuPageInRoutes(ServerError): + def __init__(self): + super().__init__( + 'The pgwui_menu in the pgwui.routes setting is ignored ' + 'and the pgwui.menu_page setting used instead') + + class BadSettingsAbort(ServerError): def __init__(self): super().__init__('Aborting due to bad setting(s)') diff --git a/src/pgwui_server/pgwui_server.py b/src/pgwui_server/pgwui_server.py index 776e3fd..2691029 100644 --- a/src/pgwui_server/pgwui_server.py +++ b/src/pgwui_server/pgwui_server.py @@ -20,7 +20,8 @@ # Karl O. Pinc -'''Provide a way to configure PGWUI. +'''Load the PGWUI components, parse the PGWUI configuration, and start the +WSGI server. ''' from pyramid.config import Configurator @@ -34,7 +35,7 @@ from pgwui_common import plugin # Constants -# All the settings recognized by PGWUI +# All the single-valued settings recognized by PGWUI_Server/Core SETTINGS = set( ['pg_host', 'pg_port', @@ -46,6 +47,18 @@ SETTINGS = set( 'autoconfigure', ]) +# All the multi-valued settings recognized by PGWUI_Server/Core +MULTI_SETTINGS = set( + ['home_page', + 'menu_page', + ]) + +# Default settings +DEFAULT_HOME_PAGE_TYPE = 'URL' +DEFAULT_HOME_PAGE_SOURCE = '/' +DEFAULT_SETTINGS = { # As delivered by configparser to this parser + 'pgwui.home_page': f'type = {DEFAULT_HOME_PAGE_TYPE}\n' + f'source = {DEFAULT_HOME_PAGE_SOURCE}\n'} # Logging log = logging.getLogger(__name__) @@ -53,25 +66,6 @@ log = logging.getLogger(__name__) # Functions -def dot_to_component_settings(settings, key, component): - '''Put a component's settings into its own dict, - adding to what's already there - ''' - comp_settings = settings['pgwui'].setdefault(component, dict()) - comp_settings.update(settings[key]) - del settings[key] - - -def component_setting_into_dict( - errors, component_checkers, key, settings, component): - '''Put a component's settings in its own dict and validate them - ''' - comp_settings = dot_to_component_settings(settings, key, component) - if component in component_checkers: - errors.extend( - component_checkers[component](comp_settings)) - - def dot_to_dict(settings, key, new_key): settings['pgwui'][new_key] = settings[key] del settings[key] @@ -107,6 +101,25 @@ def parse_assignments(lines): return result +def dot_to_multiline_setting(settings, key, pgwui_key): + '''Put a multi-line setting into its own dict, + adding to what's already there + ''' + multi_setting = settings['pgwui'].setdefault(pgwui_key, dict()) + multi_setting.update(dict(parse_assignments(settings[key]))) + del settings[key] + + +def component_setting_into_dict( + errors, component_checkers, key, settings, component): + '''Put a component's settings in its own dict and validate them + ''' + comp_settings = dot_to_multiline_setting(settings, key, component) + if component in component_checkers: + errors.extend( + component_checkers[component](comp_settings)) + + def setting_into_dict( errors, components, component_checkers, key, settings): '''Separate a pgwui setting into a dict on '.' chars; validate @@ -115,18 +128,19 @@ def setting_into_dict( if key[:6] == 'pgwui.': new_key = key[6:] if new_key in components: - settings[key] = dict(parse_assignments(settings[key])) component_setting_into_dict( errors, component_checkers, key, settings, new_key) else: if new_key in SETTINGS: dot_to_dict(settings, key, new_key) + elif new_key in MULTI_SETTINGS: + dot_to_multiline_setting(settings, key, new_key) else: errors.append(common_ex.UnknownSettingKeyError(key)) def dictify_settings(errors, settings, components): - '''Convert . in the pgwui settings to dict mappings, and validate + '''Convert "." in the pgwui settings to dict mappings, and validate the result. ''' component_checkers = plugin.find_pgwui_check_settings() @@ -136,6 +150,8 @@ def dictify_settings(errors, settings, components): errors, components, component_checkers, key, settings) checkset.validate_setting_values(errors, settings) checkset.validate_hmac(errors, settings) + checkset.validate_page_setting(errors, settings, 'home_page') + checkset.validate_page_setting(errors, settings, 'menu_page') def exit_reporting_errors(errors): @@ -153,9 +169,17 @@ def exit_reporting_errors(errors): sys.exit(1) +def add_default_settings(settings): + '''Add the default settings to the config if not there + ''' + for setting, val in DEFAULT_SETTINGS.items(): + settings.setdefault(setting, val) + + def exit_on_invalid_settings(settings, components): '''Exit when settings don't validate ''' + add_default_settings(settings) errors = [] dictify_settings(errors, settings, components) if errors: @@ -167,9 +191,13 @@ def add_routes(config, settings): ''' pgwui_settings = settings['pgwui'] if 'routes' in pgwui_settings: + menu_page = 'menu_page' in pgwui_settings routes = parse_assignments(pgwui_settings['routes']) for name, route in routes: - config.add_route(name, route) + if menu_page and name == 'pgwui_menu': + log.info(server_ex.MenuPageInRoutes()) + else: + config.add_route(name, route) def autoconfigurable_components(settings, components): diff --git a/tests/test_checkset.py b/tests/test_checkset.py index 58be20b..86aaf13 100644 --- a/tests/test_checkset.py +++ b/tests/test_checkset.py @@ -23,11 +23,15 @@ import pytest import pgwui_common.exceptions as common_ex +import pgwui_common from pgwui_server import checkset import pgwui_server.constants as constants from pgwui_server import exceptions as server_ex from pgwui_testing import testing +mock_unknown_settings = testing.make_mock_fixture( + pgwui_common.checkset, 'unknown_settings') + # key_to_ini() @@ -38,7 +42,7 @@ def test_key_to_ini(): key = 'pgwui_example' result = checkset.key_to_ini(key) - assert result == 'pgwui.' + key + assert result == 'pgwui:' + key mock_key_to_ini = testing.make_mock_fixture( @@ -51,7 +55,7 @@ mock_key_to_ini = testing.make_mock_fixture( def test_require_setting_missing(): '''Deliver exception when a required setting is missing''' errors = [] - checkset.require_setting(errors, 'key', {}) + checkset.require_setting(errors, 'key', {}, lambda x: x) assert errors assert isinstance(errors[0], common_ex.MissingSettingError) @@ -61,7 +65,7 @@ def test_require_setting_missing(): def test_require_setting_present(): '''Does nothing when a required setting is present''' errors = [] - checkset.require_setting(errors, 'key', {'key': 'value'}) + checkset.require_setting(errors, 'key', {'key': 'value'}, lambda x: x) assert errors == [] @@ -216,3 +220,371 @@ def test_validate_hmac_length(mock_do_validate_hmac): mock_validate_hmac = testing.make_mock_fixture( checkset, 'validate_hmac') + + +# page_key_to_ini() + +@pytest.mark.unittest +def test_page_key_to_ini(mock_key_to_ini): + '''key_to_ini() is called, expected result returned + ''' + mock_key_to_ini.return_value = 'foo' + result = checkset.page_key_to_ini(None, None) + assert result == 'foo' + + +mock_page_key_to_ini = testing.make_mock_fixture( + checkset, 'page_key_to_ini') + + +# require_page_settings() + +@pytest.mark.parametrize( + ('required_settings', 'rs_results', 'expected'), [ + # Settings exist, return True + (['s1', 's2'], [True, True], True), + # One setting does not exist, return False + (['s1', 's2'], [True, False], False)]) +@pytest.mark.unittest +def test_require_page_settings_result( + mock_page_key_to_ini, mock_require_setting, + required_settings, rs_results, expected): + '''Returns the expected result + ''' + mock_require_setting.side_effect = rs_results + result = checkset.require_page_settings( + None, required_settings, None, None) + assert result == expected + + +@pytest.mark.unittest +def test_require_page_settings_subfunc( + mock_page_key_to_ini, mock_require_setting): + '''Calls page_key_to_ini() when function is passed to require_setting() + ''' + def mock_rs(x, subkey, z, subkey_to_ini): + subkey_to_ini(subkey) + return True + + required_settings = ['s1', 's2'] + mock_require_setting.side_effect = mock_rs + checkset.require_page_settings(None, required_settings, None, None) + + assert mock_page_key_to_ini.call_count == len(required_settings) + + +mock_require_page_settings = testing.make_mock_fixture( + checkset, 'require_page_settings') + + +# validate_url_source() + +@pytest.mark.parametrize( + ('source', 'expected_error'), [ + ('/', None), + ('/foo', None), + ('//www.example.com', None), + ('//www.example.com/', None), + ('//www.example.com/foo', None), + ('http://www.example.com', None), + ('https://www.example.com', None), + ('anything://www.example.com', None), + ('http://www.example.com/', None), + ('http://www.example.com/foo', None), + # No domain + ('//', common_ex.BadURLSourceError), + # Nothing + ('', common_ex.BadURLSourceError), + # Missing / after scheme + ('http:/www.example.com', common_ex.BadURLSourceError), + # Extra / after scheme + ('http:///www.example.com', common_ex.BadURLSourceError)]) +@pytest.mark.unittest +def test_validate_url_source(mock_page_key_to_ini, source, expected_error): + '''The test url produces the expected error, or no error as may be + ''' + errors = [] + checkset.validate_url_source(errors, None, source) + + if expected_error: + assert len(errors) == 1 + assert isinstance(errors[0], expected_error) + else: + assert len(errors) == 0 + + +mock_validate_url_source = testing.make_mock_fixture( + checkset, 'validate_url_source') + + +# validate_url_path() + +@pytest.mark.parametrize( + ('path',), [ + ('',), + ('foo',)]) +@pytest.mark.unittest +def test_validate_url_path_no_slash(mock_page_key_to_ini, path): + '''When the path does not begin with a /, + the right error is added to errors + ''' + errors = [] + checkset.validate_url_path(errors, 'ignored', {'url_path': path}) + + assert len(errors) == 1 + assert isinstance(errors[0], common_ex.BadFileURLPathError) + + +@pytest.mark.parametrize( + ('path',), [ + ('/',), + ('/foo',)]) +@pytest.mark.unittest +def test_validate_url_path_slash(mock_page_key_to_ini, path): + '''When the path begins with a '/', no error is added to errors + ''' + errors = [] + checkset.validate_url_path(errors, 'ignored', {'url_path': path}) + + assert len(errors) == 0 + + +mock_validate_url_path = testing.make_mock_fixture( + checkset, 'validate_url_path') + + +# validate_file_source() + +@pytest.mark.parametrize( + ('source',), [ + ('',), + ('foo',)]) +@pytest.mark.unittest +def test_validate_file_source_no_slash(mock_page_key_to_ini, source): + '''When the source does not begin with a /, + the right error is added to errors + ''' + errors = [] + checkset.validate_file_source(errors, 'ignored', source) + + assert len(errors) == 1 + assert isinstance(errors[0], common_ex.BadFileSourceError) + + +@pytest.mark.parametrize( + ('source',), [ + ('/',), + ('/foo',)]) +@pytest.mark.unittest +def test_validate_file_source_slash(mock_page_key_to_ini, source): + '''When the source begins with a '/', no error is added to errors + ''' + errors = [] + checkset.validate_file_source(errors, 'ignored', source) + + assert len(errors) == 0 + + +mock_validate_file_source = testing.make_mock_fixture( + checkset, 'validate_file_source') + + +# validate_route_source() + +@pytest.mark.unittest +def test_validate_route_source_empty(mock_page_key_to_ini): + '''When there is no source the right error is added to errors + ''' + errors = [] + checkset.validate_route_source(errors, 'ignored', '') + + assert len(errors) == 1 + assert isinstance(errors[0], common_ex.BadRouteSourceError) + + +@pytest.mark.unittest +def test_validate_route_source_not_empty(mock_page_key_to_ini): + '''When there is a source no error is added to errors + ''' + errors = [] + checkset.validate_route_source(errors, 'ignored', 'something') + + assert len(errors) == 0 + + +mock_validate_route_source = testing.make_mock_fixture( + checkset, 'validate_route_source') + + +# validate_asset_source() + +@pytest.mark.unittest +def test_validate_asset_source_empty(mock_page_key_to_ini): + '''When there is no source the right error is added to errors + ''' + errors = [] + checkset.validate_asset_source(errors, 'ignored', '') + + assert len(errors) == 1 + assert isinstance(errors[0], common_ex.BadAssetSourceError) + + +@pytest.mark.unittest +def test_validate_asset_source_not_empty(mock_page_key_to_ini): + '''When there is a source no error is added to errors + ''' + errors = [] + checkset.validate_asset_source(errors, 'ignored', 'something') + + assert len(errors) == 0 + + +mock_validate_asset_source = testing.make_mock_fixture( + checkset, 'validate_asset_source') + + +# validate_file_content() + +@pytest.mark.parametrize( + ('have_settings', 'vup_called'), [ + (True, 1), + (False, 0)]) +@pytest.mark.unittest +def test_validate_file_content( + mock_validate_file_source, mock_require_page_settings, + mock_validate_url_path, + mock_unknown_settings, have_settings, vup_called): + '''validate_file_source() is called, validate_url_path() + is called when settings validate, the unknown_settings() + return value is appended to the errors + ''' + expected_errors = ['some error'] + mock_require_page_settings.return_value = have_settings + mock_unknown_settings.return_value = expected_errors + + errors = [] + checkset.validate_file_content(errors, None, None, None) + + mock_validate_file_source.assert_called_once() + mock_require_page_settings.assert_called_once() + assert mock_validate_url_path.call_count == vup_called + assert errors == expected_errors + + +mock_validate_file_content = testing.make_mock_fixture( + checkset, 'validate_file_content') + + +# validate_type_content() + +@pytest.mark.parametrize( + ('page_settings', + 'vus_called', + 'vfc_called', + 'vrs_called', + 'vas_called', + 'pkti_called', + 'error_class'), [ + # URL type + ({'type': 'URL', + 'source': 'ignored'}, + 1, 0, 0, 0, 0, + common_ex.UnknownSettingKeyError), + # file type + ({'type': 'file', + 'source': 'ignored'}, + 0, 1, 0, 0, 0, + common_ex.MissingSettingError), + # route type + ({'type': 'route', + 'source': 'ignored'}, + 0, 0, 1, 0, 0, + common_ex.UnknownSettingKeyError), + # asset type + ({'type': 'asset', + 'source': 'ignored'}, + 0, 0, 0, 1, 0, + common_ex.UnknownSettingKeyError), + # a unknown type + ({'type': 'unknown', + 'source': 'ignored'}, + 0, 0, 0, 0, 1, + common_ex.BadPageTypeError)]) +@pytest.mark.unittest +def test_validate_type_content( + mock_validate_url_source, mock_unknown_settings, + mock_validate_file_content, mock_validate_route_source, + mock_validate_asset_source, mock_page_key_to_ini, + page_settings, vus_called, vfc_called, + vrs_called, vas_called, pkti_called, error_class): + '''The expected calls are make, the expected errors returned + ''' + mock_validate_file_content.side_effect = ( + lambda errors, *args: + errors.append(common_ex.MissingSettingError('ignored'))) + mock_unknown_settings.return_value = [common_ex.UnknownSettingKeyError( + 'ignored')] + + errors = [] + checkset.validate_type_content(errors, 'some_page', page_settings) + + assert mock_validate_url_source.call_count == vus_called + assert mock_validate_file_content.call_count == vfc_called + assert mock_validate_asset_source.call_count == vas_called + assert mock_validate_route_source.call_count == vrs_called + assert len(errors) == 1 + assert isinstance(errors[0], error_class) + + +mock_validate_type_content = testing.make_mock_fixture( + checkset, 'validate_type_content') + + +# validate_page_setting() + +@pytest.mark.unittest +def test_validate_page_setting_nopage( + mock_require_page_settings, mock_validate_type_content): + '''When the page does not have a setting, nothing is done + ''' + errors = [] + settings = {'pgwui': {}} + result = checkset.validate_page_setting(errors, settings, 'test_page') + + assert errors == [] + assert result is None + mock_require_page_settings.assert_not_called() + mock_validate_type_content.assert_not_called() + + +@pytest.mark.unittest +def test_validate_page_setting_not_required( + mock_require_page_settings, mock_validate_type_content): + '''When require_page_settings() says something is missing, nothing is done + ''' + errors = [] + settings = {'pgwui': {'test_page': 'ignored'}} + mock_require_page_settings.return_value = False + result = checkset.validate_page_setting(errors, settings, 'test_page') + + assert errors == [] + assert result is None + mock_require_page_settings.assert_called_once() + mock_validate_type_content.assert_not_called() + + +@pytest.mark.unittest +def test_validate_page_setting_required( + mock_require_page_settings, mock_validate_type_content): + '''When require_page_settings() says nothing is missing, + validate_type_content() is called + ''' + errors = [] + settings = {'pgwui': {'test_page': 'ignored'}} + mock_require_page_settings.return_value = True + result = checkset.validate_page_setting(errors, settings, 'test_page') + + assert errors == [] + assert result is None + mock_require_page_settings.assert_called_once() + mock_validate_type_content.assert_called_once() diff --git a/tests/test_pgwui_server.py b/tests/test_pgwui_server.py index eb958ef..db125e0 100644 --- a/tests/test_pgwui_server.py +++ b/tests/test_pgwui_server.py @@ -78,10 +78,10 @@ mock_validate_hmac = testing.make_mock_fixture( # Unit tests -# dot_to_component_settings() +# dot_to_multiline_setting() @pytest.mark.unittest -def test_dot_to_component_settings_new(): +def test_dot_to_multiline_setting_new(mock_parse_assignments): '''Adds a new dict and puts the settings in it ''' comp_settings = {'foo': 'foo', 'bar': 'bar'} @@ -91,14 +91,15 @@ def test_dot_to_component_settings_new(): key: comp_settings} expected = {'pgwui': {component: comp_settings}} - pgwui_server.dot_to_component_settings( + mock_parse_assignments.return_value = comp_settings + pgwui_server.dot_to_multiline_setting( settings, key, 'pgwui_component') assert settings == expected @pytest.mark.unittest -def test_dot_to_component_settings_old(): +def test_dot_to_multiline_setting_old(mock_parse_assignments): '''Extends an existing dict in the settings ''' comp_settings = {'foo': 'foo', 'bar': 'bar'} @@ -109,21 +110,22 @@ def test_dot_to_component_settings_old(): expected = {'pgwui': {component: {'foo': 'foo', 'bar': 'bar', 'baz': 'baz'}}} - pgwui_server.dot_to_component_settings( + mock_parse_assignments.return_value = comp_settings + pgwui_server.dot_to_multiline_setting( settings, key, 'pgwui_component') assert settings == expected -mock_dot_to_component_setting = testing.make_mock_fixture( - pgwui_server, 'dot_to_component_settings') +mock_dot_to_multiline_setting = testing.make_mock_fixture( + pgwui_server, 'dot_to_multiline_setting') # component_setting_into_dict() @pytest.mark.unittest def test_component_setting_into_dict_no_checker( - mock_dot_to_component_setting): + mock_dot_to_multiline_setting): '''When there's no checker nothing is done ''' errors = [] @@ -136,7 +138,7 @@ def test_component_setting_into_dict_no_checker( @pytest.mark.unittest def test_component_setting_into_dict_checker( - mock_dot_to_component_setting): + mock_dot_to_multiline_setting): '''When there's a checker its result is appended to the errors ''' errors = ['someerror'] @@ -240,9 +242,9 @@ mock_parse_assignments = testing.make_mock_fixture( @pytest.mark.unittest def test_setting_into_dict_unknown( - mock_parse_assignments, mock_component_setting_into_dict, - mock_dot_to_dict): + mock_dot_to_dict, + mock_dot_to_multiline_setting): '''No new errors when there's a non-pgwui setting''' errors = [] pgwui_server.setting_into_dict(errors, [], {}, 'foo', {}) @@ -254,7 +256,8 @@ def test_setting_into_dict_unknown( def test_setting_into_dict_bad( mock_parse_assignments, mock_component_setting_into_dict, - mock_dot_to_dict): + mock_dot_to_dict, + mock_dot_to_multiline_setting): '''Delivers an error on a bad pgwui setting''' errors = [] @@ -267,24 +270,44 @@ def test_setting_into_dict_bad( @pytest.mark.unittest def test_setting_into_dict_good( - mock_parse_assignments, mock_component_setting_into_dict, - mock_dot_to_dict): - '''Calls dot_to_dict when a known pgwui setting is supplied''' + mock_dot_to_dict, + mock_dot_to_multiline_setting): + '''Calls dot_to_dict when a known pgwui setting is supplied + ''' errors = [] pgwui_server.setting_into_dict( errors, [], {}, 'pgwui.pg_host', {}) mock_dot_to_dict.assert_called_once() + mock_dot_to_multiline_setting.assert_not_called() + assert errors == [] + + +@pytest.mark.unittest +def test_setting_into_dict_multiline( + mock_component_setting_into_dict, + mock_dot_to_dict, + mock_dot_to_multiline_setting): + '''Calls dot_to_multiline_setting when a known pgwui multi-line + setting is supplied + ''' + errors = [] + + pgwui_server.setting_into_dict( + errors, [], {}, 'pgwui.home_page', {}) + + mock_dot_to_dict.assert_not_called() + mock_dot_to_multiline_setting.assert_called_once() assert errors == [] @pytest.mark.unittest def test_setting_into_dict_plugin_component( - mock_parse_assignments, mock_component_setting_into_dict, - mock_dot_to_dict): + mock_dot_to_dict, + mock_dot_to_multiline_setting): '''When a setting is for a component the setting is parsed and moved into a dict ''' @@ -296,7 +319,6 @@ def test_setting_into_dict_plugin_component( pgwui_server.setting_into_dict( errors, ['pgwui_component'], {}, key, settings) - mock_parse_assignments.assert_called_once() mock_component_setting_into_dict.assert_called_once() assert errors == [] @@ -395,27 +417,48 @@ mock_exit_reporting_errors = testing.make_mock_fixture( pgwui_server, 'exit_reporting_errors') +# add_default_settings() + +@pytest.mark.unittest +def test_add_default_settings(): + '''The default settings are added + ''' + settings = dict() + pgwui_server.add_default_settings(settings) + + assert settings == pgwui_server.DEFAULT_SETTINGS + + +mock_add_default_settings = testing.make_mock_fixture( + pgwui_server, 'add_default_settings') + + # exit_on_invalid_settings() @pytest.mark.unittest -def test_exit_on_invalid_settings_invalid(monkeypatch, - mock_exit_reporting_errors): +def test_exit_on_invalid_settings_invalid( + monkeypatch, + mock_add_default_settings, mock_dictify_settings, + mock_exit_reporting_errors): '''Calls dictify_settings and exit_reporting_errors() when setting is invalid ''' - def mock_dictify_settings(errors, settings, components): + def mymock(errors, settings, components): errors.append('error1') - monkeypatch.setattr(pgwui_server, 'dictify_settings', - mock_dictify_settings) + mock_dictify_settings.side_effect = mymock pgwui_server.exit_on_invalid_settings({}, []) + mock_dictify_settings.assert_called_once() + mock_add_default_settings.assert_called_once() assert mock_exit_reporting_errors.called @pytest.mark.unittest -def test_exit_on_invalid_settings_valid(mock_dictify_settings): +def test_exit_on_invalid_settings_valid( + mock_add_default_settings, mock_dictify_settings, + mock_exit_reporting_errors): '''Returns, without exiting, when all settings are valid ''' pgwui_server.exit_on_invalid_settings({}, []) @@ -502,13 +545,33 @@ def test_add_routes_notempty(mock_add_route, mock_parse_assignments): assert mocked_add_route.call_count == len(test_routes) +@pytest.mark.unittest +def test_add_routes_menu(mock_add_route, mock_parse_assignments, caplog): + '''When there is a a route for pgwui_menu, but there is a menu_page + setting, no route is added and an INFO message is logged + ''' + caplog.set_level(logging.DEBUG) + + test_routes = [('pgwui_menu', 'notused')] + mock_parse_assignments.return_value = test_routes + with pyramid.testing.testConfig() as config: + mocked_add_route = mock_add_route(config) + pgwui_server.add_routes(config, {'pgwui': {'routes': 'notused', + 'menu_page': 'anything'}}) + + mocked_add_route.assert_not_called() + + logs = caplog.record_tuples + assert len(logs) == 1 + assert logs[0][1] == logging.INFO + + mock_add_routes = testing.make_mock_fixture( pgwui_server, 'add_routes') # apply_component_defaults() - @pytest.mark.unittest def test_apply_component_defaults(monkeypatch, caplog, mock_autoconfigurable_components, -- 2.34.1