diff --git a/course/apps.py b/course/apps.py index 58f78b149194b87a83c9d41cb45ac2bfa2dece17..6e4d4511d02fc771faf3ef79947c312bb47f8939 100644 --- a/course/apps.py +++ b/course/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig from django.utils.translation import ugettext_lazy as _ +from course.checks import register_startup_checks_extra, register_startup_checks class CourseConfig(AppConfig): @@ -9,3 +10,7 @@ class CourseConfig(AppConfig): def ready(self): import course.receivers # noqa + + # register all checks + register_startup_checks() + register_startup_checks_extra() diff --git a/course/checks.py b/course/checks.py new file mode 100644 index 0000000000000000000000000000000000000000..a4b59f2e8b2b3820e0ac448a23bad0e8370e9260 --- /dev/null +++ b/course/checks.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import six +from django.conf import settings +from django.core.checks import Critical, Warning, register +from django.core.exceptions import ImproperlyConfigured + +REQUIRED_CONF_ERROR_PATTERN = ( + "You must configure %(location)s for RELATE to run properly.") +INSTANCE_ERROR_PATTERN = "%(location)s must be an instance of %(types)s." +GENERIC_ERROR_PATTERN = "Error in '%(location)s': %(error_type)s: %(error_str)s" + +EMAIL_CONNECTIONS = "EMAIL_CONNECTIONS" +RELATE_BASE_URL = "RELATE_BASE_URL" +RELATE_EMAIL_APPELATION_PRIORITY_LIST = "RELATE_EMAIL_APPELATION_PRIORITY_LIST" +RELATE_FACILITIES = "RELATE_FACILITIES" +RELATE_MAINTENANCE_MODE_EXCEPTIONS = "RELATE_MAINTENANCE_MODE_EXCEPTIONS" +RELATE_SESSION_RESTART_COOLDOWN_SECONDS = "RELATE_SESSION_RESTART_COOLDOWN_SECONDS" +RELATE_TICKET_MINUTES_VALID_AFTER_USE = "RELATE_TICKET_MINUTES_VALID_AFTER_USE" +GIT_ROOT = "GIT_ROOT" +RELATE_STARTUP_CHECKS = "RELATE_STARTUP_CHECKS" +RELATE_STARTUP_CHECKS_EXTRA = "RELATE_STARTUP_CHECKS_EXTRA" + +RELATE_STARTUP_CHECKS_TAG = "start_up_check" +RELATE_STARTUP_CHECKS_EXTRA_TAG = "startup_checks_extra" + + +class RelateCriticalCheckMessage(Critical): + def __init__(self, *args, **kwargs): + super(RelateCriticalCheckMessage, self).__init__(*args, **kwargs) + if not self.obj: + self.obj = ImproperlyConfigured.__name__ + + +class DeprecatedException(Exception): + pass + + +def get_ip_network(ip_range): + import ipaddress + return ipaddress.ip_network(six.text_type(ip_range)) + + +def check_relate_settings(app_configs, **kwargs): + errors = [] + + # {{{ check RELATE_BASE_URL + relate_base_url = getattr(settings, RELATE_BASE_URL, None) + if relate_base_url is None: + errors.append(RelateCriticalCheckMessage( + msg=REQUIRED_CONF_ERROR_PATTERN % {"location": RELATE_BASE_URL}, + id="relate_base_url.E001" + )) + elif not isinstance(relate_base_url, str): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_BASE_URL, "types": "str"}), + id="relate_base_url.E002" + )) + elif not relate_base_url.strip(): + errors.append(RelateCriticalCheckMessage( + msg="%(location)s should not be an empty string" + % {"location": RELATE_BASE_URL}, + id="relate_base_url.E003" + )) + # }}} + + # {{{ check RELATE_EMAIL_APPELATION_PRIORITY_LIST + relate_email_appelation_priority_list = getattr( + settings, RELATE_EMAIL_APPELATION_PRIORITY_LIST, None) + if relate_email_appelation_priority_list is not None: + if not isinstance(relate_email_appelation_priority_list, (list, tuple)): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": RELATE_EMAIL_APPELATION_PRIORITY_LIST, + "types": "list or tuple"}), + id="relate_email_appelation_priority_list.E002") + ) + # }}} + + # {{{ check EMAIL_CONNECTIONS + email_connections = getattr(settings, EMAIL_CONNECTIONS, None) + if email_connections is not None: + if not isinstance(email_connections, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": EMAIL_CONNECTIONS, + "types": "dict"}), + id="email_connections.E001" + )) + else: + for label, c in six.iteritems(email_connections): + if not isinstance(c, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": "'%s' in '%s'" + % (label, EMAIL_CONNECTIONS), + "types": "dict"}), + id="email_connections.E002" + )) + else: + if "backend" in c: + from django.utils.module_loading import import_string + try: + import_string(c["backend"]) + except ImportError as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % { + "location": + "'%s' in %s" + % (label, RELATE_FACILITIES), + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="email_connections.E003") + ) + # }}} + + # {{{ check RELATE_FACILITIES + + relate_facilities_conf = getattr(settings, RELATE_FACILITIES, None) + if relate_facilities_conf is not None: + from course.utils import get_facilities_config + try: + facilities = get_facilities_config() + except Exception as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % { + "location": RELATE_FACILITIES, + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="relate_facilities.E001") + ) + else: + if not isinstance(facilities, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + "'%(location)s' must either be or return a dictionary" + % {"location": RELATE_FACILITIES}), + id="relate_facilities.E002") + ) + else: + for facility, conf in six.iteritems(facilities): + if not isinstance(conf, dict): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": + "Facility `%s` in %s" + % (facility, RELATE_FACILITIES), + "types": "dict"}), + id="relate_facilities.E003") + ) + else: + ip_ranges = conf.get("ip_ranges", []) + if ip_ranges: + if not isinstance(ip_ranges, (list, tuple)): + errors.append(RelateCriticalCheckMessage( + msg=( + INSTANCE_ERROR_PATTERN + % {"location": + "'ip_ranges' in facility `%s` in %s" + % (facilities, RELATE_FACILITIES), + "types": "list or tuple"}), + id="relate_facilities.E004") + ) + else: + for ip_range in ip_ranges: + try: + get_ip_network(ip_range) + except Exception as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % { + "location": + "'ip_ranges' in " + "facility `%s` in %s" + % (facility, + RELATE_FACILITIES), + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="relate_facilities.E005") + ) + else: + if not callable(relate_facilities_conf): + errors.append(Warning( + msg=( + "Faclity `%s` in %s is an open facility " + "as it has no configured `ip_ranges`" + % (facility, RELATE_FACILITIES) + ), + id="relate_facilities.W001" + )) + + # }}} + + # {{{ check RELATE_MAINTENANCE_MODE_EXCEPTIONS + relate_maintenance_mode_exceptions = getattr( + settings, RELATE_MAINTENANCE_MODE_EXCEPTIONS, None) + if relate_maintenance_mode_exceptions is not None: + if not isinstance(relate_maintenance_mode_exceptions, (list, tuple)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_MAINTENANCE_MODE_EXCEPTIONS, + "types": "list or tuple"}), + id="relate_maintenance_mode_exceptions.E001") + ) + else: + for ip in relate_maintenance_mode_exceptions: + try: + get_ip_network(ip) + except Exception as e: + errors.append(RelateCriticalCheckMessage( + msg=( + GENERIC_ERROR_PATTERN + % {"location": + "ip/ip_ranges '%s' in %s" + % (ip, RELATE_FACILITIES), + "error_type": type(e).__name__, + "error_str": str(e) + }), + id="relate_maintenance_mode_exceptions.E002") + ) + # }}} + + # {{{ check RELATE_SESSION_RESTART_COOLDOWN_SECONDS + relate_session_restart_cooldown_seconds = getattr( + settings, RELATE_SESSION_RESTART_COOLDOWN_SECONDS, None) + if relate_session_restart_cooldown_seconds is not None: + if not isinstance(relate_session_restart_cooldown_seconds, (int, float)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_SESSION_RESTART_COOLDOWN_SECONDS, + "types": "int or float"}), + id="relate_session_restart_cooldown_seconds.E001") + ) + else: + if relate_session_restart_cooldown_seconds < 0: + errors.append(RelateCriticalCheckMessage( + msg=( + "%(location)s must be a positive number, " + "got %(value)s instead" + % {"location": RELATE_SESSION_RESTART_COOLDOWN_SECONDS, + "value": relate_session_restart_cooldown_seconds}), + id="relate_session_restart_cooldown_seconds.E002") + ) + + # }}} + + # {{{ check RELATE_SESSION_RESTART_COOLDOWN_SECONDS + relate_ticket_minutes_valid_after_use = getattr( + settings, RELATE_TICKET_MINUTES_VALID_AFTER_USE, None) + if relate_ticket_minutes_valid_after_use is not None: + if not isinstance(relate_ticket_minutes_valid_after_use, (int, float)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": RELATE_TICKET_MINUTES_VALID_AFTER_USE, + "types": "int or float"}), + id="relate_ticket_minutes_valid_after_use.E001") + ) + else: + if relate_ticket_minutes_valid_after_use < 0: + errors.append(RelateCriticalCheckMessage( + msg=( + "%(location)s must be a positive number, " + "got %(value)s instead" + % {"location": RELATE_TICKET_MINUTES_VALID_AFTER_USE, + "value": relate_ticket_minutes_valid_after_use}), + id="relate_ticket_minutes_valid_after_use.E002") + ) + + # }}} + + # {{{ check GIT_ROOT + git_root = getattr(settings, GIT_ROOT, None) + if git_root is None: + errors.append(RelateCriticalCheckMessage( + msg=REQUIRED_CONF_ERROR_PATTERN % {"location": GIT_ROOT}, + id="git_root.E001" + )) + elif not isinstance(git_root, str): + errors.append(RelateCriticalCheckMessage( + msg=INSTANCE_ERROR_PATTERN % {"location": GIT_ROOT, "types": "str"}, + id="git_root.E002" + )) + else: + import os + if not os.path.isdir(git_root): + errors.append(RelateCriticalCheckMessage( + msg=("`%(path)s` connfigured in %(location)s is not a valid path" + % {"path": git_root, "location": GIT_ROOT}), + id="git_root.E003" + )) + else: + if not os.access(git_root, os.W_OK): + errors.append(RelateCriticalCheckMessage( + msg=("`%(path)s` connfigured in %(location)s is not writable " + "by RELATE" + % {"path": git_root, "location": GIT_ROOT}), + id="git_root.E004" + )) + if not os.access(git_root, os.R_OK): + errors.append(RelateCriticalCheckMessage( + msg=("`%(path)s` connfigured in %(location)s is not readable " + "by RELATE" + % {"path": git_root, "location": GIT_ROOT}), + id="git_root.E005" + )) + + # }}} + + return errors + + +def register_startup_checks(): + register(check_relate_settings, RELATE_STARTUP_CHECKS_TAG) + + +def register_startup_checks_extra(): + """ + Register extra checks provided by user. + Here we will have to raise error for Exceptions, as that can not be done + via check: all checks, including check_relate_settings, will only be + executed after self.ready() is done. + """ + startup_checks_extra = getattr(settings, RELATE_STARTUP_CHECKS_EXTRA, None) + if startup_checks_extra: + if not isinstance(startup_checks_extra, (list, tuple)): + raise ImproperlyConfigured( + INSTANCE_ERROR_PATTERN + % {"location": RELATE_STARTUP_CHECKS_EXTRA, + "types": "list or tuple" + } + ) + from django.utils.module_loading import import_string + for c in startup_checks_extra: + try: + check_item = import_string(c) + except Exception as e: + raise ImproperlyConfigured( + GENERIC_ERROR_PATTERN + % { + "location": RELATE_STARTUP_CHECKS_EXTRA, + "error_type": type(e).__name__, + "error_str": str(e) + }) + else: + register(check_item, RELATE_STARTUP_CHECKS_EXTRA_TAG) + +# vim: foldmethod=marker diff --git a/local_settings.example.py b/local_settings.example.py index 93d468b697334b3f6d905216c7a5932ddfd8d73b..a7fbdb3831685430b0184948cf8074ca32d03204 100644 --- a/local_settings.example.py +++ b/local_settings.example.py @@ -231,6 +231,23 @@ RELATE_SHOW_EDITOR_FORM = True # }}} +# {{{ extra checks + +# This allow user to add customized startup checkes for user-defined modules +# using Django's system checks (https://docs.djangoproject.com/en/dev/ref/checks/) +# For example, define a `my_check_func in `my_module` with +# +# def my_check_func(app_configs, **kwargs): +# return [list of error] +# +# The configuration should be +# RELATE_STARTUP_CHECKS_EXTRA = ["my_module.my_check_func"] +# i.e., Each item should be the path to an importable check function. +#RELATE_STARTUP_CHECKS_EXTRA = [] + +# }}} + + # {{{ docker # A string containing the image ID of the docker image to be used to run diff --git a/relate/settings.py b/relate/settings.py index 88045f2fd26083e9203618da2534365d02495ffc..46694888923a1d6c785027ee8a81105ff44204bc 100644 --- a/relate/settings.py +++ b/relate/settings.py @@ -56,7 +56,7 @@ INSTALLED_APPS = ( "course", ) -if local_settings["RELATE_SIGN_IN_BY_SAML2_ENABLED"]: +if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"): INSTALLED_APPS = INSTALLED_APPS + ("djangosaml2",) # type: ignore # }}} @@ -89,7 +89,7 @@ AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", ) -if local_settings["RELATE_SIGN_IN_BY_SAML2_ENABLED"]: +if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"): AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS + ( # type: ignore 'course.auth.Saml2Backend', ) @@ -287,12 +287,4 @@ SAML_CREATE_UNKNOWN_USER = True # }}} -# This makes sure the RELATE_BASE_URL is configured. -assert local_settings["RELATE_BASE_URL"] - -# This makes sure RELATE_EMAIL_APPELATION_PRIORITY_LIST is a list -if "RELATE_EMAIL_APPELATION_PRIORITY_LIST" in local_settings: - assert isinstance( - local_settings["RELATE_EMAIL_APPELATION_PRIORITY_LIST"], list) - # vim: foldmethod=marker diff --git a/test/base_test_mixins.py b/test/base_test_mixins.py index 6925d7e938fb55ebeb7d56b7de8a752b3e8f6002..5d7a01cc3fcf54846eb5ff0ba04d1331d398817b 100644 --- a/test/base_test_mixins.py +++ b/test/base_test_mixins.py @@ -101,7 +101,12 @@ def force_remove_path(path): func(path) import shutil - shutil.rmtree(path, onerror=remove_readonly) + try: + shutil.rmtree(path, onerror=remove_readonly) + except OSError: + # let the remove_exceptionally_undelete_course_repos method to delete + # the folder for the next test. + pass class SuperuserCreateMixin(object): diff --git a/test/test_checks.py b/test/test_checks.py new file mode 100644 index 0000000000000000000000000000000000000000..13242c58f25d7808f8dd082a93d47bfce71cb3c6 --- /dev/null +++ b/test/test_checks.py @@ -0,0 +1,468 @@ +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import os +from django.test import SimpleTestCase +from django.test.utils import override_settings +try: + from unittest import mock +except: + import mock + + +class CheckRelateSettingsBase(SimpleTestCase): + @property + def func(self): + from course.checks import check_relate_settings + return check_relate_settings + + +class CheckRelateURL(CheckRelateSettingsBase): + VALID_CONF = "example.com" + INVALID_CONF_NONE = None + INVALID_CONF_EMPTY_LIST = [] + INVALID_CONF_SPACES = " " + + @override_settings(RELATE_BASE_URL=VALID_CONF) + def test_valid_relate_base_url1(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_BASE_URL=INVALID_CONF_NONE) + def test_invalid_relate_base_url_none(self): + result = self.func(None) + self.assertEqual([r.id for r in result], ["relate_base_url.E001"]) + + @override_settings(RELATE_BASE_URL=INVALID_CONF_EMPTY_LIST) + def test_invalid_relate_base_url_empty_list(self): + result = self.func(None) + self.assertEqual([r.id for r in result], ["relate_base_url.E002"]) + + @override_settings(RELATE_BASE_URL=INVALID_CONF_SPACES) + def test_invalid_relate_base_url_spaces(self): + result = self.func(None) + self.assertEqual([r.id for r in result], ["relate_base_url.E003"]) + + +class CheckRelateEmailAppelationPriorityList(CheckRelateSettingsBase): + VALID_CONF_NONE = None + VALID_CONF = ["name1", "name2"] + INVALID_CONF_STR = "name1" + + @override_settings(RELATE_EMAIL_APPELATION_PRIORITY_LIST=VALID_CONF_NONE) + def test_valid_relate_email_appelation_priority_list_none(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_EMAIL_APPELATION_PRIORITY_LIST=VALID_CONF) + def test_valid_relate_email_appelation_priority_list(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_EMAIL_APPELATION_PRIORITY_LIST=INVALID_CONF_STR) + def test_invalid_relate_email_appelation_priority_list_str(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_email_appelation_priority_list.E002"]) + + +class CheckRelateEmailConnections(CheckRelateSettingsBase): + VALID_CONF_NONE = None + VALID_CONF_EMPTY_DICT = {} + VALID_CONF = { + "robot": { + 'backend': 'django.core.mail.backends.console.EmailBackend', + 'host': 'smtp.gmail.com', + 'username': 'blah@blah.com', + 'password': 'password', + 'port': 587, + 'use_tls': True, + }, + "other": {} + } + INVALID_CONF_EMPTY_LIST = [] + INVALID_CONF_LIST = [VALID_CONF] + INVALID_CONF_LIST_AS_ITEM_VALUE = { + "robot": ['blah@blah.com'], + "other": [], + "yet_another": {} + } + INVALID_CONF_INVALID_BACKEND = { + "robot": { + 'backend': 'an.invalid.emailBackend', # invalid backend + 'host': 'smtp.gmail.com', + 'username': 'blah@blah.com', + 'password': 'password', + 'port': 587, + 'use_tls': True, + }, + "other": {} + } + + @override_settings(EMAIL_CONNECTIONS=VALID_CONF_NONE) + def test_valid_email_connections_none(self): + self.assertEqual(self.func(None), []) + + @override_settings(EMAIL_CONNECTIONS=VALID_CONF_EMPTY_DICT) + def test_valid_email_connections_emtpy_dict(self): + self.assertEqual(self.func(None), []) + + @override_settings(EMAIL_CONNECTIONS=VALID_CONF) + def test_valid_email_connections(self): + self.assertEqual(self.func(None), []) + + @override_settings(EMAIL_CONNECTIONS=INVALID_CONF_EMPTY_LIST) + def test_invalid_email_connections_empty_list(self): + self.assertEqual([r.id for r in self.func(None)], + ["email_connections.E001"]) + + @override_settings(EMAIL_CONNECTIONS=INVALID_CONF_LIST) + def test_invalid_email_connections_list(self): + self.assertEqual([r.id for r in self.func(None)], + ["email_connections.E001"]) + + @override_settings(EMAIL_CONNECTIONS=INVALID_CONF_LIST_AS_ITEM_VALUE) + def test_invalid_email_connections_list_as_item_value(self): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual([r.id for r in result], + ["email_connections.E002", + "email_connections.E002"]) + + @override_settings(EMAIL_CONNECTIONS=INVALID_CONF_INVALID_BACKEND) + def test_invalid_email_connections_invalid_backend(self): + self.assertEqual([r.id for r in self.func(None)], + ["email_connections.E003"]) + + +class CheckRelateFacilities(CheckRelateSettingsBase): + VALID_CONF_NONE = None + VALID_CONF = ( + { + "test_center": { + "ip_ranges": ["192.168.192.0/24"], + "exams_only": False}, + "test_center2": { + "ip_ranges": ["192.168.10.0/24"]}, + }) + + INVALID_CONF_LIST = [] + INVALID_CONF_NOT_DICT_AS_ITEM_VALUE = ( + { + "test_center": { + "ip_ranges": ["192.168.192.0/24"], + "exams_only": False}, + "test_center2": [], # not a dict + "test_center3": ("192.168.10.0/24"), # not a dict + }) + + INVALID_CONF_IP_RANGES_NOT_LIST = ( + { + "test_center": { + "ip_ranges": "192.168.192.0/24", # not a list + "exams_only": False}, + "test_center2": [], + }) + + INVALID_CONF_IP_RANGES_ITEM_NOT_IPADDRESS = ( + { + "test_center": { + "ip_ranges": ["www.example.com", "localhost"] # invalid ipadd + }, + }) + + WARNING_CONF_IP_RANGES_NOT_CONFIGURED = ( + { + "test_center": {"exams_only": False}, + "test_center2": {}, + }) + + @override_settings(RELATE_FACILITIES=VALID_CONF_NONE) + def test_valid_relate_facilities_none(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_FACILITIES=VALID_CONF) + def test_valid_relate_facilities(self): + self.assertEqual(self.func(None), []) + + def test_valid_relate_facilities_callable(self): + def valid_func(now_datetime): + from django.utils.timezone import now + if now_datetime > now(): + return self.VALID_CONF + else: + return {} + + with override_settings(RELATE_FACILITIES=valid_func): + self.assertEqual(self.func(None), []) + + def test_valid_relate_facilities_callable_with_empty_ip_ranges(self): + def valid_func_though_return_emtpy_ip_ranges(now_datetime): + # this won't result in warnning, because the facility is defined + # by a callable. + return self.WARNING_CONF_IP_RANGES_NOT_CONFIGURED + with override_settings( + RELATE_FACILITIES=valid_func_though_return_emtpy_ip_ranges): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_FACILITIES=INVALID_CONF_LIST) + def test_invalid_relate_facilities_callable_return_list(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_facilities.E002"]) + + @override_settings(RELATE_FACILITIES=INVALID_CONF_NOT_DICT_AS_ITEM_VALUE) + def test_invalid_relate_facilities_callable_not_dict_as_item_value(self): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual([r.id for r in result], + ["relate_facilities.E003", + "relate_facilities.E003"]) + + @override_settings(RELATE_FACILITIES=INVALID_CONF_IP_RANGES_NOT_LIST) + def test_invalid_relate_facilities_ip_ranges_not_list(self): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual(sorted([r.id for r in result]), + sorted(["relate_facilities.E003", + "relate_facilities.E004"])) + + @override_settings(RELATE_FACILITIES=INVALID_CONF_IP_RANGES_ITEM_NOT_IPADDRESS) + def test_invalid_relate_facilities_ip_ranges_item_not_ipaddress(self): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual(sorted([r.id for r in result]), + sorted(["relate_facilities.E005", + "relate_facilities.E005"])) + + def test_invalid_relate_facilities_callable_not_return_dict(self): + def invalid_func_not_return_dict(now_datetime): + return self.INVALID_CONF_LIST + + with override_settings(RELATE_FACILITIES=invalid_func_not_return_dict): + self.assertEqual([r.id for r in self.func(None)], + ["relate_facilities.E001"]) + + def test_invalid_relate_facilities_callable_return_invalid_conf(self): + def invalid_func_return_invalid_conf(now_datetime): + return self.INVALID_CONF_NOT_DICT_AS_ITEM_VALUE + + with override_settings(RELATE_FACILITIES=invalid_func_return_invalid_conf): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual([r.id for r in result], + ["relate_facilities.E003", + "relate_facilities.E003"]) + + def test_invalid_relate_facilities_callable_return_none(self): + def invalid_func_return_none(now_datetime): + return None + + with override_settings(RELATE_FACILITIES=invalid_func_return_none): + self.assertEqual([r.id for r in self.func(None)], + ["relate_facilities.E001"]) + + @override_settings(RELATE_FACILITIES=WARNING_CONF_IP_RANGES_NOT_CONFIGURED) + def test_warning_relate_facilities(self): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual([r.id for r in result], + ["relate_facilities.W001", + "relate_facilities.W001"]) + + +class CheckRelateMaintenanceModeExceptions(CheckRelateSettingsBase): + VALID_CONF_NONE = None + VALID_CONF_EMPTY_LIST = [] + VALID_CONF = ["127.0.0.1", "192.168.1.1"] + INVALID_CONF_STR = "127.0.0.1" + INVALID_CONF_DICT = {"localhost": "127.0.0.1", + "www.myrelate.com": "192.168.1.1"} + INVALID_CONF_INVALID_IPS = ["localhost", "www.myrelate.com"] + + @override_settings(RELATE_MAINTENANCE_MODE_EXCEPTIONS=VALID_CONF_NONE) + def test_valid_maintenance_mode_exceptions_none(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_MAINTENANCE_MODE_EXCEPTIONS=VALID_CONF_EMPTY_LIST) + def test_valid_maintenance_mode_exceptions_emtpy_list(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_MAINTENANCE_MODE_EXCEPTIONS=VALID_CONF) + def test_valid_maintenance_mode_exceptions(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_MAINTENANCE_MODE_EXCEPTIONS=INVALID_CONF_STR) + def test_invalid_maintenance_mode_exceptions_str(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_maintenance_mode_exceptions.E001"]) + + @override_settings(RELATE_MAINTENANCE_MODE_EXCEPTIONS=INVALID_CONF_DICT) + def test_invalid_maintenance_mode_exceptions_dict(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_maintenance_mode_exceptions.E001"]) + + @override_settings(RELATE_MAINTENANCE_MODE_EXCEPTIONS=INVALID_CONF_INVALID_IPS) + def test_invalid_maintenance_mode_exceptions_invalid_ipaddress(self): + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual([r.id for r in result], + ["relate_maintenance_mode_exceptions.E002", + "relate_maintenance_mode_exceptions.E002"]) + + +class CheckRelateSessionRestartCooldownSeconds(CheckRelateSettingsBase): + VALID_CONF = 10 + VALID_CONF_BY_CALC = 2 * 5 + INVALID_CONF_STR = "10" + INVALID_CONF_LIST = [10] + INVALID_CONF_NEGATIVE = -10 + + @override_settings(RELATE_SESSION_RESTART_COOLDOWN_SECONDS=VALID_CONF) + def test_valid_relate_session_restart_cooldown_seconds(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_SESSION_RESTART_COOLDOWN_SECONDS=VALID_CONF_BY_CALC) + def test_valid_relate_session_restart_cooldown_seconds_by_calc(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_SESSION_RESTART_COOLDOWN_SECONDS=INVALID_CONF_STR) + def test_invalid_maintenance_mode_exceptions_str(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_session_restart_cooldown_seconds.E001"]) + + @override_settings(RELATE_SESSION_RESTART_COOLDOWN_SECONDS=INVALID_CONF_LIST) + def test_invalid_maintenance_mode_exceptions_list(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_session_restart_cooldown_seconds.E001"]) + + @override_settings(RELATE_SESSION_RESTART_COOLDOWN_SECONDS=INVALID_CONF_NEGATIVE) + def test_invalid_maintenance_mode_exceptions_list_negative(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_session_restart_cooldown_seconds.E002"]) + + +class CheckRelateTicketMinutesValidAfterUse(CheckRelateSettingsBase): + VALID_CONF = 10 + VALID_CONF_BY_CALC = 2 * 5 + INVALID_CONF_STR = "10" + INVALID_CONF_LIST = [10] + INVALID_CONF_NEGATIVE = -10 + + @override_settings(RELATE_TICKET_MINUTES_VALID_AFTER_USE=VALID_CONF) + def test_valid_relate_ticket_minutes_valid_after_use(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_TICKET_MINUTES_VALID_AFTER_USE=VALID_CONF_BY_CALC) + def test_valid_relate_ticket_minutes_valid_after_use_by_calc(self): + self.assertEqual(self.func(None), []) + + @override_settings(RELATE_TICKET_MINUTES_VALID_AFTER_USE=INVALID_CONF_STR) + def test_invalid_relate_ticket_minutes_valid_after_use_str(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_ticket_minutes_valid_after_use.E001"]) + + @override_settings(RELATE_TICKET_MINUTES_VALID_AFTER_USE=INVALID_CONF_LIST) + def test_invalid_relate_ticket_minutes_valid_after_use_list(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_ticket_minutes_valid_after_use.E001"]) + + @override_settings(RELATE_TICKET_MINUTES_VALID_AFTER_USE=INVALID_CONF_NEGATIVE) + def test_invalid_relate_ticket_minutes_valid_after_use_negative(self): + self.assertEqual([r.id for r in self.func(None)], + ["relate_ticket_minutes_valid_after_use.E002"]) + + +def side_effect_os_path_is_dir(*args, **kwargs): + if args[0].startswith("dir"): + return True + return False + + +def side_effect_os_access(*args, **kwargs): + if args[0].endswith("NEITHER"): + return False + elif args[0].endswith("W_FAIL"): + if args[1] == os.W_OK: + return False + elif args[0].endswith("R_FAIL"): + if args[1] == os.R_OK: + return False + return True + + +@mock.patch('os.access', side_effect=side_effect_os_access) +@mock.patch("os.path.isdir", side_effect=side_effect_os_path_is_dir) +class CheckGitRoot(CheckRelateSettingsBase): + VALID_GIT_ROOT = "dir/git/root/path" + + INVALID_GIT_ROOT_NONE = None + INVALID_GIT_ROOT_LIST = [VALID_GIT_ROOT] + INVALID_GIT_ROOT_SPACES = " " + INVALID_GIT_ROOT_NOT_DIR = "not_dir/git/root/path" + INVALID_GIT_ROOT_W_FAIL = "dir/git/root/path/W_FAIL" + INVALID_GIT_ROOT_R_FAIL = "dir/git/root/path/R_FAIL" + INVALID_GIT_ROOT_W_R_FAIL = "dir/git/root/path/NEITHER" + + @override_settings(GIT_ROOT=VALID_GIT_ROOT) + def test_valid_git_root(self, mocked_os_access, mocked_os_path_is_dir): + self.assertEqual(self.func(None), []) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_NONE) + def test_invalid_git_root_none(self, mocked_os_access, mocked_os_path_is_dir): + self.assertEqual([r.id for r in self.func(None)], + ["git_root.E001"]) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_LIST) + def test_invalid_git_root_list(self, mocked_os_access, mocked_os_path_is_dir): + self.assertEqual([r.id for r in self.func(None)], + ["git_root.E002"]) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_SPACES) + def test_invalid_git_root_spaces(self, mocked_os_access, mocked_os_path_is_dir): + self.assertEqual([r.id for r in self.func(None)], + ["git_root.E003"]) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_NOT_DIR) + def test_invalid_git_root(self, mocked_os_access, mocked_os_path_is_dir): + self.assertEqual([r.id for r in self.func(None)], + ["git_root.E003"]) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_W_FAIL) + def test_invalid_git_root_no_write_perm( + self, mocked_os_access, mocked_os_path_is_dir): + # no write permission + self.assertEqual([r.id for r in self.func(None)], + ["git_root.E004"]) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_R_FAIL) + def test_invalid_git_root_no_read_perms( + self, mocked_os_access, mocked_os_path_is_dir): + # no read permission + self.assertEqual([r.id for r in self.func(None)], + ["git_root.E005"]) + + @override_settings(GIT_ROOT=INVALID_GIT_ROOT_W_R_FAIL) + def test_invalid_git_root_no_both_perms( + self, mocked_os_access, mocked_os_path_is_dir): + # no write and read permissions + result = self.func(None) + self.assertEqual(len(result), 2) + self.assertEqual([r.id for r in result], + ["git_root.E004", "git_root.E005"])