From 0c4bf1e8de0320ebcd8a4496bc76796bdb8b8b88 Mon Sep 17 00:00:00 2001 From: JensDiemer Date: Mon, 28 Dec 2020 18:52:29 +0100 Subject: [PATCH] get pytest running with local test copy --- .github/workflows/pytest.yml | 2 +- .gitignore | 2 + Makefile | 9 +- conf/django_ynh_demo_settings.py | 2 + conf/django_ynh_demo_urls.py | 4 +- conf/setup_user.py | 8 + django_ynh/local_test.py | 169 ++++++++++++++++++ django_ynh/path_utils.py | 25 +++ django_ynh/pytest_helper.py | 34 ++++ django_ynh/pytest_plugin.py | 45 +++++ django_ynh/sso_auth/auth_backend.py | 9 +- django_ynh/sso_auth/auth_middleware.py | 22 +-- django_ynh/sso_auth/signals.py | 12 -- django_ynh/sso_auth/user_profile.py | 43 ++++- django_ynh/test_tools/YnhTestCase.py | 7 - django_ynh/test_utils.py | 8 + django_ynh/views.py | 3 + django_ynh_tests/__init__.py | 0 django_ynh_tests/test_app/__init__.py | 0 .../test_app/management/__init__.py | 0 .../test_app/management/commands/__init__.py | 0 .../management/commands/run_testserver.py | 39 ---- django_ynh_tests/test_app/models.py | 1 - django_ynh_tests/test_project/__init__.py | 0 django_ynh_tests/test_project/manage.py | 18 -- django_ynh_tests/test_project/publish.py | 28 --- django_ynh_tests/test_project/settings.py | 64 ------- django_ynh_tests/test_project/signals.py | 26 --- django_ynh_tests/test_project/urls.py | 11 -- django_ynh_tests/test_project/wsgi.py | 9 - local_test.py | 146 +-------------- pyproject.toml | 6 +- run_pytest.py | 25 +++ {django_ynh/test_tools => tests}/__init__.py | 0 tests/test_django_ynh.py | 130 ++++++++++++++ tests/test_utils.py | 8 + 36 files changed, 529 insertions(+), 386 deletions(-) create mode 100644 conf/setup_user.py create mode 100755 django_ynh/local_test.py create mode 100644 django_ynh/path_utils.py create mode 100644 django_ynh/pytest_helper.py create mode 100644 django_ynh/pytest_plugin.py delete mode 100644 django_ynh/sso_auth/signals.py delete mode 100644 django_ynh/test_tools/YnhTestCase.py create mode 100644 django_ynh/test_utils.py delete mode 100644 django_ynh_tests/__init__.py delete mode 100644 django_ynh_tests/test_app/__init__.py delete mode 100644 django_ynh_tests/test_app/management/__init__.py delete mode 100644 django_ynh_tests/test_app/management/commands/__init__.py delete mode 100644 django_ynh_tests/test_app/management/commands/run_testserver.py delete mode 100644 django_ynh_tests/test_app/models.py delete mode 100644 django_ynh_tests/test_project/__init__.py delete mode 100755 django_ynh_tests/test_project/manage.py delete mode 100644 django_ynh_tests/test_project/publish.py delete mode 100644 django_ynh_tests/test_project/settings.py delete mode 100644 django_ynh_tests/test_project/signals.py delete mode 100644 django_ynh_tests/test_project/urls.py delete mode 100644 django_ynh_tests/test_project/wsgi.py mode change 100755 => 100644 local_test.py create mode 100644 run_pytest.py rename {django_ynh/test_tools => tests}/__init__.py (100%) create mode 100644 tests/test_django_ynh.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 68940b0..e190805 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -30,7 +30,7 @@ jobs: - name: 'Run tests with Python v${{ matrix.python-version }}' run: | - poetry run pytest + make pytest - name: 'Upload coverage report' run: bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore index c901e52..999db84 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ !.editorconfig !.flake8 !.gitignore +coverage.xml __pycache__ secret.txt +/htmlcov/ /local_test/ /dist/ /poetry.lock diff --git a/Makefile b/Makefile index 86358fe..7027e04 100644 --- a/Makefile +++ b/Makefile @@ -41,15 +41,14 @@ tox-listenvs: check-poetry ## List all tox test environments tox: check-poetry ## Run pytest via tox with all environments poetry run tox -pytest: check-poetry ## Run pytest - poetry run pytest +pytest: install ## Run pytest + poetry run python3 ./run_pytest.py publish: ## Release new version to PyPi poetry run publish -local-test: check-poetry ## Run local_test.py to run the project locally - poetry install - poetry run ./local_test.py +local-test: install ## Run local_test.py to run the project locally + poetry run python3 ./local_test.py local-diff-settings: ## Run "manage.py diffsettings" with local test poetry run python3 local_test/opt_yunohost/manage.py diffsettings diff --git a/conf/django_ynh_demo_settings.py b/conf/django_ynh_demo_settings.py index 2ef986d..afecb94 100644 --- a/conf/django_ynh_demo_settings.py +++ b/conf/django_ynh_demo_settings.py @@ -40,6 +40,8 @@ PATH_URL = PATH_URL.strip('/') ROOT_URLCONF = 'django_ynh_demo_urls' +YNH_SETUP_USER = 'setup_user.setup_demo_user' + SECRET_KEY = __get_or_create_secret(FINAL_HOME_PATH / 'secret.txt') # /opt/yunohost/$app/secret.txt ADMINS = (('__ADMIN__', '__ADMINMAIL__'),) diff --git a/conf/django_ynh_demo_urls.py b/conf/django_ynh_demo_urls.py index e7ff176..9cf9d56 100644 --- a/conf/django_ynh_demo_urls.py +++ b/conf/django_ynh_demo_urls.py @@ -10,10 +10,8 @@ from django_ynh.views import request_media_debug_view # Prefix all urls with "PATH_URL": urlpatterns = [ path(f'{settings.PATH_URL}/', admin.site.urls), + path(f'{settings.PATH_URL}/debug/', request_media_debug_view), ] if settings.SERVE_FILES: urlpatterns += static.static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - -if settings.DEBUG: - urlpatterns.append(path(f'{settings.PATH_URL}/debug/', request_media_debug_view)) diff --git a/conf/setup_user.py b/conf/setup_user.py new file mode 100644 index 0000000..1b55b11 --- /dev/null +++ b/conf/setup_user.py @@ -0,0 +1,8 @@ + +def setup_demo_user(user): + """ + The django_ynh DEMO use the Django admin. So we need a "staff" user ;) + """ + user.is_staff = True + user.save() + return user diff --git a/django_ynh/local_test.py b/django_ynh/local_test.py new file mode 100755 index 0000000..6a35342 --- /dev/null +++ b/django_ynh/local_test.py @@ -0,0 +1,169 @@ +""" + Create a YunoHost package local test +""" +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +from django_ynh.path_utils import assert_is_dir, assert_is_file +from django_ynh.test_utils import generate_basic_auth + + +def verbose_check_call(command, verbose=True, **kwargs): + """ 'verbose' version of subprocess.check_call() """ + if verbose: + print('_' * 100) + msg = f'Call: {command!r}' + verbose_kwargs = ', '.join(f'{k}={v!r}' for k, v in sorted(kwargs.items())) + if verbose_kwargs: + msg += f' (kwargs: {verbose_kwargs})' + print(f'{msg}\n', flush=True) + + env = os.environ.copy() + env['PYTHONUNBUFFERED'] = '1' + + popenargs = shlex.split(command) + subprocess.check_call(popenargs, universal_newlines=True, env=env, **kwargs) + + +def call_manage_py(final_home_path, args): + verbose_check_call( + command=f'{sys.executable} manage.py {args}', + cwd=final_home_path, + ) + + +def copy_patch(src_file, replaces, final_home_path): + dst_file = final_home_path / src_file.name + print(f'{src_file} -> {dst_file}') + + with src_file.open('r') as f: + content = f.read() + + if replaces: + for old, new in replaces.items(): + if old in content: + print(f' * Replace "{old}" -> "{new}"') + content = content.replace(old, new) + + with dst_file.open('w') as f: + f.write(content) + + +def create_local_test(django_settings_path, destination, runserver=False): + assert_is_file(django_settings_path) + + django_settings_name = django_settings_path.stem + + conf_path = django_settings_path.parent + base_path = conf_path.parent + + assert isinstance(destination, Path) + destination = destination.resolve() + if not destination.is_dir(): + destination.mkdir(parents=False) + + assert_is_dir(destination) + + FINAL_HOME_PATH = destination / 'opt_yunohost' + FINAL_WWW_PATH = destination / 'var_www' + LOG_FILE = destination / 'var_log_django_ynh.log' + + REPLACES = { + '__FINAL_HOME_PATH__': str(FINAL_HOME_PATH), + '__FINAL_WWW_PATH__': str(FINAL_WWW_PATH), + '__LOG_FILE__': str(destination / 'var_log_django_ynh.log'), + '__PATH_URL__': 'app_path', + '__DOMAIN__': '127.0.0.1', + 'django.db.backends.postgresql': 'django.db.backends.sqlite3', + "'NAME': '__APP__',": f"'NAME': '{destination / 'test_db.sqlite'}',", + 'django_redis.cache.RedisCache': 'django.core.cache.backends.dummy.DummyCache', + # Just use the default logging setup from django_ynh project: + 'LOGGING = {': 'HACKED_DEACTIVATED_LOGGING = {', + } + + for p in (FINAL_HOME_PATH, FINAL_WWW_PATH): + if p.is_dir(): + print(f'Already exists: "{p}", ok.') + else: + p.mkdir(parents=True, exist_ok=True) + + LOG_FILE.touch(exist_ok=True) + + for src_file in conf_path.glob('*.py'): + copy_patch(src_file=src_file, replaces=REPLACES, final_home_path=FINAL_HOME_PATH) + + with Path(FINAL_HOME_PATH / 'local_settings.py').open('w') as f: + f.write('# Only for local test run\n') + f.write('SERVE_FILES = True # used in src/inventory_project/urls.py\n') + f.write('AUTH_PASSWORD_VALIDATORS = [] # accept all passwords\n') + + # call "local_test/manage.py" via subprocess: + call_manage_py(FINAL_HOME_PATH, 'check --deploy') + if runserver: + call_manage_py(FINAL_HOME_PATH, 'migrate --no-input') + call_manage_py(FINAL_HOME_PATH, 'collectstatic --no-input') + + verbose_check_call( + command=( + f'{sys.executable} -m django_ynh.create_superuser' + f' --ds="{django_settings_name}" --username="test" --password="test123"' + ), + cwd=FINAL_HOME_PATH, + ) + + os.environ['DJANGO_SETTINGS_MODULE'] = django_settings_name + + # All environment variables are passed to Django's "runnserver" ;) + # "Simulate" SSOwat authentication, by set "http headers" + # Still missing is the 'SSOwAuthUser' cookie, + # but this is ignored, if settings.DEBUG=True ;) + os.environ['HTTP_AUTH_USER'] = 'test' + os.environ['HTTP_REMOTE_USER'] = 'test' + + os.environ['HTTP_AUTHORIZATION'] = generate_basic_auth(username='test', password='test123') + + try: + call_manage_py(FINAL_HOME_PATH, 'runserver --nostatic') + except KeyboardInterrupt: + print('\nBye ;)') + + return FINAL_HOME_PATH + + +def cli(): + parser = argparse.ArgumentParser(description='Generate a YunoHost package local test') + + parser.add_argument( + '--django_settings_path', + action='store', + metavar='path', + help='Path to YunoHost package settings.py file (in "conf" directory)', + ) + parser.add_argument( + '--destination', + action='store', + metavar='path', + help='Destination directory for the local test files', + ) + parser.add_argument( + '--runserver', + action='store', + type=bool, + default=False, + help='Start Django "runserver" after local test file creation?', + ) + args = parser.parse_args() + + create_local_test( + django_settings_path=Path(args.django_settings_path), + destination=Path(args.destination), + runserver=args.runserver, + ) + + +if __name__ == '__main__': + cli() diff --git a/django_ynh/path_utils.py b/django_ynh/path_utils.py new file mode 100644 index 0000000..dffcfe1 --- /dev/null +++ b/django_ynh/path_utils.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +def assert_is_dir(dir_path): + assert isinstance(dir_path, Path) + assert dir_path.is_dir, f'Directory does not exists: {dir_path}' + + +def assert_is_file(file_path): + assert isinstance(file_path, Path) + assert file_path.is_file, f'File not found: {file_path}' + + +def is_relative_to(p, other): + """ + Path.is_relative_to() is new in Python 3.9 + """ + p = Path(p) + other = Path(other) + try: + p.relative_to(other) + except ValueError: + return False + else: + return True diff --git a/django_ynh/pytest_helper.py b/django_ynh/pytest_helper.py new file mode 100644 index 0000000..e488e28 --- /dev/null +++ b/django_ynh/pytest_helper.py @@ -0,0 +1,34 @@ +import os +import sys +from pathlib import Path + +from django_ynh.local_test import create_local_test +from django_ynh.path_utils import assert_is_dir, assert_is_file + + +def run_pytest(django_settings_path, destination): + assert_is_file(django_settings_path) + + conf_path = django_settings_path.parent + base_path = conf_path.parent + test_path = Path(base_path / 'tests') + + assert_is_dir(test_path) + + final_home_path = create_local_test( + django_settings_path=django_settings_path, + destination=destination, + runserver=False, + ) + django_settings_name = django_settings_path.stem + os.environ['DJANGO_SETTINGS_MODULE'] = django_settings_name + print(f'DJANGO_SETTINGS_MODULE={django_settings_name}') + + sys.path.insert(0, str(final_home_path)) + + import pytest + + # collect only project tests: + sys.argv = [__file__, str(test_path)] + + raise SystemExit(pytest.console_main()) diff --git a/django_ynh/pytest_plugin.py b/django_ynh/pytest_plugin.py new file mode 100644 index 0000000..652d9ed --- /dev/null +++ b/django_ynh/pytest_plugin.py @@ -0,0 +1,45 @@ +import os +import sys +from pathlib import Path + +import pytest + +from django_ynh.path_utils import assert_is_dir, assert_is_file + + +def pytest_addoption(parser): + group = parser.getgroup("django_ynh") + group.addoption( + "--django_settings_path", + action="store", + metavar="path", + help='Path to YunoHost package settings.py file (in "conf" directory)', + ) + + +@pytest.hookimpl(tryfirst=True) +def pytest_load_initial_conftests(early_config, parser, args): + base_path = Path(__file__).parent.parent + + local_test = Path(base_path / 'local_test') + assert_is_dir(local_test) + + local_test_opt_yunohost = Path(local_test / 'opt_yunohost') + assert_is_dir(local_test_opt_yunohost) + + assert_is_file(local_test_opt_yunohost / 'django_ynh_demo_settings.py') + + sys.path.insert(0, str(local_test_opt_yunohost)) + os.environ['DJANGO_SETTINGS_MODULE'] = 'django_ynh_demo_settings' + + # print(1111111111111, early_config) # _pytest.config.Config + # print(22222, parser) # _pytest.config.argparsing.Parser + # print(3333, args) # ['--import-mode=importlib', '--reuse-db', '--nomigrations', '--cov=.', '--cov-report', 'term-missing', '--cov-report', 'html', '--cov-report', 'xml', '--no-cov-on-fail', '--showlocals', '--doctest-modules', '--failed-first', '--last-failed-no-failures', 'all', '--new-first', '-p', 'django_ynh.pytest_plugin', '-x', '--trace-config'] + # + # options = parser.parse_known_args(args) + # if options.version or options.help: + # return + # + # conf_path = options.conf_path + # + # raise RuntimeError(f'{conf_path}') diff --git a/django_ynh/sso_auth/auth_backend.py b/django_ynh/sso_auth/auth_backend.py index bc36120..9f8e506 100644 --- a/django_ynh/sso_auth/auth_backend.py +++ b/django_ynh/sso_auth/auth_backend.py @@ -27,8 +27,7 @@ import logging from django.contrib.auth.backends import RemoteUserBackend -from django_ynh.sso_auth.signals import setup_user -from django_ynh.sso_auth.user_profile import update_user_profile +from django_ynh.sso_auth.user_profile import call_setup_user, update_user_profile logger = logging.getLogger(__name__) @@ -51,12 +50,12 @@ class SSOwatUserBackend(RemoteUserBackend): """ logger.warning('Configure user %s', user) - user = update_user_profile(request) - - setup_user.send(sender=self.__class__, user=user) + user = update_user_profile(request, user) + user = call_setup_user(user=user) return user def user_can_authenticate(self, user): logger.warning('Remote user login: %s', user) + assert not user.is_anonymous return True diff --git a/django_ynh/sso_auth/auth_middleware.py b/django_ynh/sso_auth/auth_middleware.py index e842d19..839aa99 100644 --- a/django_ynh/sso_auth/auth_middleware.py +++ b/django_ynh/sso_auth/auth_middleware.py @@ -5,8 +5,7 @@ from axes.exceptions import AxesBackendPermissionDenied from django.conf import settings from django.contrib.auth.middleware import RemoteUserMiddleware -from django_ynh.sso_auth.signals import setup_user -from django_ynh.sso_auth.user_profile import update_user_profile +from django_ynh.sso_auth.user_profile import call_setup_user, update_user_profile logger = logging.getLogger(__name__) @@ -28,8 +27,10 @@ class SSOwatRemoteUserMiddleware(RemoteUserMiddleware): super().process_request(request) # login remote user - if not request.user.is_authenticated: - # Not logged in -> nothing to verify here + user = request.user + + if not user.is_authenticated: + logger.debug('Not logged in -> nothing to verify here') return # Check SSOwat cookie informations: @@ -47,7 +48,7 @@ class SSOwatRemoteUserMiddleware(RemoteUserMiddleware): raise AxesBackendPermissionDenied('Cookie missing') else: logger.info('SSOwat username from cookies: %r', username) - if username != request.user.username: + if username != user.username: raise AxesBackendPermissionDenied('Wrong username') # Compare with HTTP_AUTH_USER @@ -57,7 +58,7 @@ class SSOwatRemoteUserMiddleware(RemoteUserMiddleware): logger.error('HTTP_AUTH_USER missing!') raise AxesBackendPermissionDenied('No HTTP_AUTH_USER') - if username != request.user.username: + if username != user.username: raise AxesBackendPermissionDenied('Wrong HTTP_AUTH_USER username') # Also check 'HTTP_AUTHORIZATION', but only the username ;) @@ -74,11 +75,12 @@ class SSOwatRemoteUserMiddleware(RemoteUserMiddleware): creds = str(base64.b64decode(creds), encoding='utf-8') username = creds.split(':', 1)[0] - if username != request.user.username: + if username != user.username: raise AxesBackendPermissionDenied('Wrong HTTP_AUTHORIZATION username') if not was_authenticated: # First request, after login -> update user informations - logger.info('Remote used was logged in') - user = update_user_profile(request) - setup_user.send(sender=self.__class__, user=user) + logger.info('Remote user "%s" was logged in', user) + user = update_user_profile(request, user) + + user = call_setup_user(user=user) diff --git a/django_ynh/sso_auth/signals.py b/django_ynh/sso_auth/signals.py deleted file mode 100644 index 81f5a83..0000000 --- a/django_ynh/sso_auth/signals.py +++ /dev/null @@ -1,12 +0,0 @@ -""" - "setup_user" called via: - - * SSOwatUserBackend after a new user was created - * SSOwatRemoteUserMiddleware on login request -""" - - -import django.dispatch - - -setup_user = django.dispatch.Signal(providing_args=['user']) diff --git a/django_ynh/sso_auth/user_profile.py b/django_ynh/sso_auth/user_profile.py index 906d07d..d0dbc9f 100644 --- a/django_ynh/sso_auth/user_profile.py +++ b/django_ynh/sso_auth/user_profile.py @@ -1,12 +1,48 @@ import logging +from functools import lru_cache +from django.conf import settings +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError +from django.utils.module_loading import import_string logger = logging.getLogger(__name__) -def update_user_profile(request): +UserModel = get_user_model() + + +@lru_cache(maxsize=None) +def get_setup_user_func(): + setup_user_func = import_string(settings.YNH_SETUP_USER) + assert callable(setup_user_func) + return setup_user_func + + +def call_setup_user(user): + """ + Hook for the YunoHost package application to setup a Django user. + Call function defined in settings.YNH_SETUP_USER + + called via: + * SSOwatUserBackend after a new user was created + * SSOwatRemoteUserMiddleware on login request + """ + old_pk = user.pk + + setup_user_func = get_setup_user_func() + logger.debug('Call "%s" for user "%s"', settings.YNH_SETUP_USER, user) + + user = setup_user_func(user=user) + + assert isinstance(user, UserModel) + assert user.pk == old_pk + + return user + + +def update_user_profile(request, user): """ Update existing user information: * Email @@ -16,12 +52,9 @@ def update_user_profile(request): * SSOwatUserBackend after a new user was created * SSOwatRemoteUserMiddleware on login request """ - user = request.user - assert user.is_authenticated - update_fields = [] - if not user.has_usable_password(): + if user.is_authenticated and not user.has_usable_password(): # Empty password is not valid, so we can't save the model, because of full_clean() call logger.info('Set unusable password for user: %s', user) user.set_unusable_password() diff --git a/django_ynh/test_tools/YnhTestCase.py b/django_ynh/test_tools/YnhTestCase.py deleted file mode 100644 index e5417fb..0000000 --- a/django_ynh/test_tools/YnhTestCase.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.test.testcases import TestCase - - -class YnhTestCase(TestCase): - """ - - """ diff --git a/django_ynh/test_utils.py b/django_ynh/test_utils.py new file mode 100644 index 0000000..0e78986 --- /dev/null +++ b/django_ynh/test_utils.py @@ -0,0 +1,8 @@ +import base64 + + +def generate_basic_auth(username, password): + basic_auth = f'{username}:{password}' + basic_auth_creds = bytes(basic_auth, encoding='utf-8') + creds = str(base64.b64encode(basic_auth_creds), encoding='utf-8') + return f'basic {creds}' diff --git a/django_ynh/views.py b/django_ynh/views.py index f05934e..057ddaa 100644 --- a/django_ynh/views.py +++ b/django_ynh/views.py @@ -1,6 +1,7 @@ import logging import pprint +from django.conf import settings from django.contrib.auth import get_user_model from django.http.response import HttpResponse from django.shortcuts import redirect @@ -12,6 +13,8 @@ logger = logging.getLogger(__name__) def request_media_debug_view(request): """ debug request.META """ + assert settings.DEBUG is True, 'Only in DEBUG mode available!' + if not request.user.is_authenticated: logger.info('Deny debug view: User not logged in!') UserModel = get_user_model() diff --git a/django_ynh_tests/__init__.py b/django_ynh_tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_ynh_tests/test_app/__init__.py b/django_ynh_tests/test_app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_ynh_tests/test_app/management/__init__.py b/django_ynh_tests/test_app/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_ynh_tests/test_app/management/commands/__init__.py b/django_ynh_tests/test_app/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_ynh_tests/test_app/management/commands/run_testserver.py b/django_ynh_tests/test_app/management/commands/run_testserver.py deleted file mode 100644 index 47a57b5..0000000 --- a/django_ynh_tests/test_app/management/commands/run_testserver.py +++ /dev/null @@ -1,39 +0,0 @@ -import os - -from django.contrib.auth import get_user_model -from django.core.management import BaseCommand, call_command - - -class Command(BaseCommand): - """ - Expand django.contrib.staticfiles runserver - """ - - help = "Setup test project and run django developer server" - - def verbose_call(self, command, *args, **kwargs): - self.stderr.write("_" * 79) - self.stdout.write(f"Call {command!r} with: {args!r} {kwargs!r}") - call_command(command, *args, **kwargs) - - def handle(self, *args, **options): - - if "RUN_MAIN" not in os.environ: - # RUN_MAIN added by auto reloader, see: django/utils/autoreload.py - - # Create migrations for our test app - # But these migrations should never commit! - # On changes: Just delete the SQLite file and start fresh ;) - self.verbose_call("makemigrations") - - self.verbose_call("migrate") - - # django.contrib.staticfiles.management.commands.collectstatic.Command - self.verbose_call("collectstatic", interactive=False, link=True) - - User = get_user_model() - qs = User.objects.filter(is_active=True, is_superuser=True) - if qs.count() == 0: - self.verbose_call("createsuperuser") - - self.verbose_call("runserver", use_threading=False, use_reloader=True, verbosity=2) diff --git a/django_ynh_tests/test_app/models.py b/django_ynh_tests/test_app/models.py deleted file mode 100644 index ba336bf..0000000 --- a/django_ynh_tests/test_app/models.py +++ /dev/null @@ -1 +0,0 @@ -# no models ;) diff --git a/django_ynh_tests/test_project/__init__.py b/django_ynh_tests/test_project/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_ynh_tests/test_project/manage.py b/django_ynh_tests/test_project/manage.py deleted file mode 100755 index 5bae0a8..0000000 --- a/django_ynh_tests/test_project/manage.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python3 -import sys - - -def main(): - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() diff --git a/django_ynh_tests/test_project/publish.py b/django_ynh_tests/test_project/publish.py deleted file mode 100644 index 54814b8..0000000 --- a/django_ynh_tests/test_project/publish.py +++ /dev/null @@ -1,28 +0,0 @@ -""" - Helper to publish this Project to PyPi -""" - -from pathlib import Path - -from poetry_publish.publish import poetry_publish -from poetry_publish.utils.subprocess_utils import verbose_check_call - -import django_ynh - - -PACKAGE_ROOT = Path(django_ynh.__file__).parent.parent - - -def publish(): - """ - Publish to PyPi - Call this via: - $ poetry run publish - """ - verbose_check_call('poetry', 'check') - - # TODO: - # verbose_check_call('make', 'pytest') # don't publish if tests fail - # verbose_check_call('make', 'fix-code-style') # don't publish if code style wrong - - poetry_publish(package_root=PACKAGE_ROOT, version=django_ynh.__version__) diff --git a/django_ynh_tests/test_project/settings.py b/django_ynh_tests/test_project/settings.py deleted file mode 100644 index e17205a..0000000 --- a/django_ynh_tests/test_project/settings.py +++ /dev/null @@ -1,64 +0,0 @@ -from pathlib import Path - - -BASE_DIR = Path(__file__).parent.parent - - -SECRET_KEY = 'Only a test project!' - - -DEBUG = True - -ALLOWED_HOSTS = [] - - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_ynh', # <<<< -] - - - -ROOT_URLCONF = 'django_ynh_tests.test_project.urls' - - - -WSGI_APPLICATION = 'django_ynh_tests.test_project.wsgi.application' - - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - - -AUTH_PASSWORD_VALIDATORS = [] # Just a test project, so no restrictions - - -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_L10N = True -USE_TZ = True -LOCALE_PATHS = (BASE_DIR.parent / 'django_ynh' / 'locale',) - - -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'static' - -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' - - -INTERNAL_IPS = [ - '127.0.0.1', -] - - diff --git a/django_ynh_tests/test_project/signals.py b/django_ynh_tests/test_project/signals.py deleted file mode 100644 index 0a2b4ca..0000000 --- a/django_ynh_tests/test_project/signals.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging - -from django.dispatch import receiver - -from django_ynh.sso_auth.signals import setup_user - - -logger = logging.getLogger(__name__) - - -@receiver(setup_user) -def setup_user_handler(sender, **kwargs): - """ - Make user to a "staff" user, so he can use the Django admin. - - This Signal is called via: - * SSOwatUserBackend after a new user was created - * SSOwatRemoteUserMiddleware on login request - """ - user = kwargs['user'] - logger.info('Receive "setup_user" signal for user: "%s"', user) - - if not user.is_staff: - user.is_staff = True - user.save(update_fields=['is_staff']) - logger.info('Make user %s to a staff user', user) diff --git a/django_ynh_tests/test_project/urls.py b/django_ynh_tests/test_project/urls.py deleted file mode 100644 index e4ba611..0000000 --- a/django_ynh_tests/test_project/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -import debug_toolbar -from django.contrib import admin -from django.urls import include, path -from django.views.generic import RedirectView - - -urlpatterns = [ - path('admin/', admin.site.urls), - path('', RedirectView.as_view(url='/admin/')), - path('__debug__/', include(debug_toolbar.urls)), -] diff --git a/django_ynh_tests/test_project/wsgi.py b/django_ynh_tests/test_project/wsgi.py deleted file mode 100644 index d9598cf..0000000 --- a/django_ynh_tests/test_project/wsgi.py +++ /dev/null @@ -1,9 +0,0 @@ -""" - WSGI config -""" - - -from django.core.wsgi import get_wsgi_application - - -application = get_wsgi_application() diff --git a/local_test.py b/local_test.py old mode 100755 new mode 100644 index 49ce2dc..6be93c7 --- a/local_test.py +++ b/local_test.py @@ -8,156 +8,24 @@ see README for details ;) """ -import base64 -import os -import shlex -import subprocess -import sys from pathlib import Path -os.environ['DJANGO_SETTINGS_MODULE'] = 'django_ynh_demo_settings' - try: - import django_ynh # noqa + from django_ynh.local_test import create_local_test except ImportError as err: - raise ImportError('Couldn\'t import django_ynh. Did you ' 'forget to activate a virtual environment?') from err + raise ImportError('Did you forget to activate a virtual environment?') from err - -BASE_PATH = Path(__file__).parent.absolute() -TEST_PATH = BASE_PATH / 'local_test' -CONF_PATH = BASE_PATH / 'conf' - -FINAL_HOME_PATH = TEST_PATH / 'opt_yunohost' -FINAL_WWW_PATH = TEST_PATH / 'var_www' -LOG_FILE = TEST_PATH / 'var_log_django_ynh.log' - -MANAGE_PY_FILE = CONF_PATH / 'manage.py' -SETTINGS_FILE = CONF_PATH / 'django_ynh_demo_settings.py' -URLS_FILE = CONF_PATH / 'django_ynh_demo_urls.py' - -REPLACES = { - '__FINAL_HOME_PATH__': str(FINAL_HOME_PATH), - '__FINAL_WWW_PATH__': str(FINAL_WWW_PATH), - '__LOG_FILE__': str(TEST_PATH / 'var_log_django_ynh.log'), - '__PATH_URL__': 'app_path', - '__DOMAIN__': '127.0.0.1', - 'django.db.backends.postgresql': 'django.db.backends.sqlite3', - "'NAME': '__APP__',": f"'NAME': '{TEST_PATH / 'test_db.sqlite'}',", - 'django_redis.cache.RedisCache': 'django.core.cache.backends.dummy.DummyCache', - - # Just use the default logging setup from django_ynh project: - 'LOGGING = {': 'HACKED_DEACTIVATED_LOGGING = {', -} - - -def verbose_check_call(command, verbose=True, **kwargs): - """ 'verbose' version of subprocess.check_call() """ - if verbose: - print('_' * 100) - msg = f'Call: {command!r}' - verbose_kwargs = ', '.join(f'{k}={v!r}' for k, v in sorted(kwargs.items())) - if verbose_kwargs: - msg += f' (kwargs: {verbose_kwargs})' - print(f'{msg}\n', flush=True) - - env = os.environ.copy() - env['PYTHONUNBUFFERED'] = '1' - - popenargs = shlex.split(command) - subprocess.check_call(popenargs, universal_newlines=True, env=env, **kwargs) - - -def call_manage_py(args): - verbose_check_call( - command=f'{sys.executable} manage.py {args}', - cwd=FINAL_HOME_PATH, - ) - - -def copy_patch(src_file, replaces=None): - dst_file = FINAL_HOME_PATH / src_file.name - print(f'{src_file.relative_to(BASE_PATH)} -> {dst_file.relative_to(BASE_PATH)}') - - with src_file.open('r') as f: - content = f.read() - - if replaces: - for old, new in replaces.items(): - content = content.replace(old, new) - - with dst_file.open('w') as f: - f.write(content) - - -def assert_is_dir(dir_path): - assert dir_path.is_dir, f'Directory does not exists: {dir_path}' - - -def assert_is_file(file_path): - assert file_path.is_file, f'File not found: {file_path}' +BASE_PATH = Path(__file__).parent def main(): - print('-' * 100) - - assert_is_dir(BASE_PATH) - assert_is_dir(CONF_PATH) - assert_is_file(SETTINGS_FILE) - assert_is_file(URLS_FILE) - - for p in (TEST_PATH, FINAL_HOME_PATH, FINAL_WWW_PATH): - if p.is_dir(): - print(f'Already exists: "{p.relative_to(BASE_PATH)}", ok.') - else: - print(f'Create: "{p.relative_to(BASE_PATH)}"') - p.mkdir(parents=True, exist_ok=True) - - LOG_FILE.touch(exist_ok=True) - - # conf/manage.py -> local_test/manage.py - copy_patch(src_file=MANAGE_PY_FILE) - - # conf/django_ynh_demo_settings.py -> local_test/django_ynh_demo_settings.py - copy_patch(src_file=SETTINGS_FILE, replaces=REPLACES) - - # conf/ynh_urls.py -> local_test/ynh_urls.py - copy_patch(src_file=URLS_FILE, replaces=REPLACES) - - with Path(FINAL_HOME_PATH / 'local_settings.py').open('w') as f: - f.write('# Only for local test run\n') - f.write('SERVE_FILES = True # used in src/inventory_project/urls.py\n') - f.write('AUTH_PASSWORD_VALIDATORS = [] # accept all passwords\n') - - # call "local_test/manage.py" via subprocess: - call_manage_py('check --deploy') - call_manage_py('migrate --no-input') - call_manage_py('collectstatic --no-input') - - verbose_check_call( - command=( - f'{sys.executable} -m django_ynh.create_superuser' - ' --ds="django_ynh_demo_settings" --username="test" --password="test"' - ), - cwd=FINAL_HOME_PATH, + create_local_test( + django_settings_path=BASE_PATH / 'conf' / 'django_ynh_demo_settings.py', + destination=BASE_PATH / 'local_test', + runserver=True, ) - # All environment variables are passed to Django's "runnserver" ;) - # "Simulate" SSOwat authentication, by set "http headers" - # Still missing is the 'SSOwAuthUser' cookie, - # but this is ignored, if settings.DEBUG=True ;) - os.environ['HTTP_AUTH_USER'] = 'test' - os.environ['HTTP_REMOTE_USER'] = 'test' - - creds = str(base64.b64encode(b'test:test'), encoding='utf-8') - basic_auth = f'basic {creds}' - os.environ['HTTP_AUTHORIZATION'] = basic_auth - - try: - call_manage_py('runserver --nostatic') - except KeyboardInterrupt: - print('\nBye ;)') - if __name__ == '__main__': main() diff --git a/pyproject.toml b/pyproject.toml index e5ff84a..beba137 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,12 +13,14 @@ packages = [ [tool.poetry.dependencies] python = ">=3.7,<4.0.0" django = "*" +gunicorn = "*" django-axes = "*" # https://github.com/jazzband/django-axes psycopg2-binary = "*" django-redis = "*" [tool.poetry.dev-dependencies] poetry-publish = "*" # https://github.com/jedie/poetry-publish +bx_py_utils = "*" tox = "*" pytest = "*" pytest-randomly = "*" @@ -57,8 +59,7 @@ lines_after_imports=2 [tool.pytest.ini_options] # https://docs.pytest.org/en/latest/customize.html#pyproject-toml minversion = "6.0" -DJANGO_SETTINGS_MODULE="django_ynh_project.settings.tests" -norecursedirs = ".* .git __pycache__ coverage* dist htmlcov volumes" +norecursedirs = ".* .git __pycache__ conf coverage* dist htmlcov volumes" # sometimes helpfull "addopts" arguments: # -vv # --verbose @@ -80,7 +81,6 @@ addopts = """ --failed-first --last-failed-no-failures all --new-first - -p no:randomly """ diff --git a/run_pytest.py b/run_pytest.py new file mode 100644 index 0000000..ecc1ab2 --- /dev/null +++ b/run_pytest.py @@ -0,0 +1,25 @@ +""" + Run pytest against local test creation +""" + +from pathlib import Path + + +try: + from django_ynh.pytest_helper import run_pytest +except ImportError as err: + raise ImportError('Did you forget to activate a virtual environment?') from err + + +BASE_PATH = Path(__file__).parent + + +def main(): + run_pytest( + django_settings_path=BASE_PATH / 'conf' / 'django_ynh_demo_settings.py', + destination=BASE_PATH / 'local_test', + ) + + +if __name__ == '__main__': + main() diff --git a/django_ynh/test_tools/__init__.py b/tests/__init__.py similarity index 100% rename from django_ynh/test_tools/__init__.py rename to tests/__init__.py diff --git a/tests/test_django_ynh.py b/tests/test_django_ynh.py new file mode 100644 index 0000000..18c48fb --- /dev/null +++ b/tests/test_django_ynh.py @@ -0,0 +1,130 @@ +from axes.models import AccessAttempt, AccessLog +from bx_py_utils.test_utils.html_assertion import HtmlAssertionMixin +from django.conf import settings +from django.contrib.auth.models import User +from django.test import override_settings +from django.test.testcases import TestCase +from django.urls.base import reverse + +from django_ynh.test_utils import generate_basic_auth +from django_ynh.views import request_media_debug_view + + +@override_settings(DEBUG=False) +class DjangoYnhTestCase(HtmlAssertionMixin, TestCase): + def setUp(self): + super().setUp() + + # Always start a fresh session: + self.client = self.client_class() + + def test_settings(self): + assert settings.PATH_URL == 'app_path' + + assert str(settings.FINAL_HOME_PATH).endswith('/local_test/opt_yunohost') + assert str(settings.FINAL_WWW_PATH).endswith('/local_test/var_www') + assert str(settings.LOG_FILE).endswith('/local_test/var_log_django_ynh.log') + + assert settings.ROOT_URLCONF == 'django_ynh_demo_urls' + + def test_urls(self): + assert reverse('admin:index') == '/app_path/' + assert reverse(request_media_debug_view) == '/app_path/debug/' + + def test_auth(self): + response = self.client.get('/app_path/') + self.assertRedirects(response, expected_url='/app_path/login/?next=/app_path/') + + def test_create_unknown_user(self): + assert User.objects.count() == 0 + + self.client.cookies['SSOwAuthUser'] = 'test' + + response = self.client.get( + path='/app_path/', + HTTP_REMOTE_USER='test', + HTTP_AUTH_USER='test', + HTTP_AUTHORIZATION='basic dGVzdDp0ZXN0MTIz', + ) + + assert User.objects.count() == 1 + user = User.objects.first() + assert user.username == 'test' + assert user.is_active is True + assert user.is_staff is True # Set by: conf.django_ynh_demo_urls.setup_user_handler + assert user.is_superuser is False + + self.assert_html_parts( + response, parts=('Site administration | Django site admin', 'test') + ) + + def test_wrong_auth_user(self): + assert User.objects.count() == 0 + assert AccessLog.objects.count() == 0 + + self.client.cookies['SSOwAuthUser'] = 'test' + + response = self.client.get( + path='/app_path/', + HTTP_REMOTE_USER='test', + HTTP_AUTH_USER='foobar', # <<< wrong user name + HTTP_AUTHORIZATION='basic dGVzdDp0ZXN0MTIz', + ) + + assert User.objects.count() == 1 + user = User.objects.first() + assert user.username == 'test' + assert user.is_active is True + assert user.is_staff is True # Set by: conf.django_ynh_demo_urls.setup_user_handler + assert user.is_superuser is False + + assert AccessLog.objects.count() == 1 + + assert response.status_code == 403 # Forbidden + + def test_wrong_cookie(self): + assert User.objects.count() == 0 + assert AccessLog.objects.count() == 0 + + self.client.cookies['SSOwAuthUser'] = 'foobar' # <<< wrong user name + + response = self.client.get( + path='/app_path/', + HTTP_REMOTE_USER='test', + HTTP_AUTH_USER='test', + HTTP_AUTHORIZATION='basic dGVzdDp0ZXN0MTIz', + ) + + assert User.objects.count() == 1 + user = User.objects.first() + assert user.username == 'test' + assert user.is_active is True + assert user.is_staff is True # Set by: conf.django_ynh_demo_urls.setup_user_handler + assert user.is_superuser is False + + assert AccessLog.objects.count() == 1 + + assert response.status_code == 403 # Forbidden + + def test_wrong_authorization_user(self): + assert User.objects.count() == 0 + + self.client.cookies['SSOwAuthUser'] = 'test' + + response = self.client.get( + path='/app_path/', + HTTP_REMOTE_USER='test', + HTTP_AUTH_USER='test', + HTTP_AUTHORIZATION=generate_basic_auth(username='foobar', password='test123'), # <<< wrong user name + ) + + assert User.objects.count() == 1 + user = User.objects.first() + assert user.username == 'test' + assert user.is_active is True + assert user.is_staff is True # Set by: conf.django_ynh_demo_urls.setup_user_handler + assert user.is_superuser is False + + assert AccessLog.objects.count() == 1 + + assert response.status_code == 403 # Forbidden diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a9927a7 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,8 @@ +from unittest.case import TestCase + +from django_ynh.test_utils import generate_basic_auth + + +class TestUtilsTestCase(TestCase): + def test_generate_basic_auth(self): + assert generate_basic_auth(username='test', password='test123') == 'basic dGVzdDp0ZXN0MTIz'