diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe0f411..aaf2a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: branches: - main tags: - - '*' + - "*" pull_request: workflow_dispatch: @@ -15,8 +15,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ['3.12', '3.13'] - django: ['4.2', '5.2'] + python: ["3.12", "3.13"] + django: ["5.2"] name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}) @@ -51,7 +51,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: "3.12" - name: Build sdist and wheel run: | diff --git a/.gitignore b/.gitignore index 894a44c..ad5ec24 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ venv.bak/ # mypy .mypy_cache/ + +reports diff --git a/open_api_framework/conf/base.py b/open_api_framework/conf/base.py index 638dde1..2b34c64 100644 --- a/open_api_framework/conf/base.py +++ b/open_api_framework/conf/base.py @@ -1,30 +1,40 @@ import datetime import os import warnings -from pathlib import Path +from contextlib import suppress +from importlib.util import find_spec from django.urls import reverse_lazy import sentry_sdk -from corsheaders.defaults import default_headers as default_cors_headers -from csp.constants import NONCE, NONE, SELF from log_outgoing_requests.formatters import HttpFormatter -from notifications_api_common.settings import * # noqa from .utils import ( config, get_django_project_dir, get_project_dirname, get_sentry_integrations, + importable, strip_protocol_from_origin, ) +# optional requirements +default_cors_headers = [] +with suppress(ImportError): + from corsheaders.defaults import default_headers as default_cors_headers + +csp_installed = False +with suppress(ImportError): + from csp.constants import NONCE, NONE, SELF + + csp_installed = True + PROJECT_DIRNAME = get_project_dirname() # Build paths inside the project, so further paths can be defined relative to # the code root. DJANGO_PROJECT_DIR = get_django_project_dir() -BASE_DIR = Path(DJANGO_PROJECT_DIR).resolve().parents[1] +BASE_DIR = DJANGO_PROJECT_DIR.resolve().parents[1] # @@ -307,53 +317,55 @@ def connect( # https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-DEFAULT_AUTO_FIELD DEFAULT_AUTO_FIELD = "django.db.models.AutoField" -CACHE_DEFAULT = config( - "CACHE_DEFAULT", - "localhost:6379/0", - help_text="redis cache address for the default cache (this **MUST** be set when using Docker)", - group="Required", -) -CACHE_AXES = config( - "CACHE_AXES", - "localhost:6379/0", - help_text=( - "redis cache address for the brute force login protection cache " - "(this **MUST** be set when using Docker)" - ), - group="Required", -) +if find_spec("django_redis"): + CACHE_DEFAULT = config( + "CACHE_DEFAULT", + "localhost:6379/0", + help_text="redis cache address for the default cache (this **MUST** be set when using Docker)", + group="Required", + ) + CACHE_AXES = config( + "CACHE_AXES", + "localhost:6379/0", + help_text=( + "redis cache address for the brute force login protection cache " + "(this **MUST** be set when using Docker)" + ), + group="Required", + ) -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://{CACHE_DEFAULT}", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "IGNORE_EXCEPTIONS": True, + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://{CACHE_DEFAULT}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "IGNORE_EXCEPTIONS": True, + }, }, - }, - "axes": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://{CACHE_AXES}", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "IGNORE_EXCEPTIONS": True, + "axes": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://{CACHE_AXES}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "IGNORE_EXCEPTIONS": True, + }, }, - }, - "oidc": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://{CACHE_DEFAULT}", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "IGNORE_EXCEPTIONS": True, + "oidc": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://{CACHE_DEFAULT}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "IGNORE_EXCEPTIONS": True, + }, }, - }, -} + } + # # APPLICATIONS enabled for this project # -INSTALLED_APPS = [ +INSTALLED_APPS = importable( # Note: contenttypes should be first, see Django ticket #10827 "django.contrib.contenttypes", "django.contrib.auth", @@ -396,9 +408,9 @@ def connect( PROJECT_DIRNAME, # Django libraries "upgrade_check", -] +) -MIDDLEWARE = [ +MIDDLEWARE = importable( "django.middleware.security.SecurityMiddleware", "sessionprofile.middleware.SessionProfileMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -411,7 +423,7 @@ def connect( "django.middleware.clickjacking.XFrameOptionsMiddleware", "axes.middleware.AxesMiddleware", "csp.contrib.rate_limiting.RateLimitedCSPMiddleware", -] +) ROOT_URLCONF = f"{PROJECT_DIRNAME}.urls" @@ -424,7 +436,7 @@ def connect( TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [Path(DJANGO_PROJECT_DIR) / "templates"], + "DIRS": [DJANGO_PROJECT_DIR / "templates"], "APP_DIRS": False, # conflicts with explicity specifying the loaders "OPTIONS": { "context_processors": [ @@ -443,7 +455,7 @@ def connect( WSGI_APPLICATION = f"{PROJECT_DIRNAME}.wsgi.application" # Translations -LOCALE_PATHS = (Path(DJANGO_PROJECT_DIR) / "conf" / "locale",) +LOCALE_PATHS = (DJANGO_PROJECT_DIR / "conf" / "locale",) # # SERVING of static and media files @@ -451,10 +463,10 @@ def connect( STATIC_URL = "/static/" -STATIC_ROOT = Path(BASE_DIR) / "static" +STATIC_ROOT = BASE_DIR / "static" # Additional locations of static files -STATICFILES_DIRS = [Path(DJANGO_PROJECT_DIR) / "static"] +STATICFILES_DIRS = [DJANGO_PROJECT_DIR / "static"] # List of finder classes that know how to find static files in # various locations. @@ -463,7 +475,7 @@ def connect( "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] -MEDIA_ROOT = Path(BASE_DIR) / "media" +MEDIA_ROOT = BASE_DIR / "media" MEDIA_URL = "/media/" @@ -568,6 +580,7 @@ def connect( help_text="control the verbosity of logging output for celery, independent of ``LOG_LEVEL``." " Available values are ``CRITICAL``, ``ERROR``, ``WARNING``, ``INFO`` and ``DEBUG``", group="Celery", + add_to_docs="celery", ) _USE_STRUCTLOG = config("_USE_STRUCTLOG", default=False, add_to_docs=False) @@ -578,10 +591,11 @@ def connect( default=True, help_text=("enable structured logging of requests"), group="Logging", + add_to_docs="django_structlog", ) -LOGGING_DIR = Path(BASE_DIR) / "log" +LOGGING_DIR = BASE_DIR / "log" if _USE_STRUCTLOG: import structlog @@ -668,7 +682,7 @@ def connect( "json_file": { "level": LOG_LEVEL, # always debug might be better? "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "application.jsonl", + "filename": LOGGING_DIR / "application.jsonl", "formatter": "json", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -676,7 +690,7 @@ def connect( "performance": { "level": "INFO", "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "performance.log", + "filename": LOGGING_DIR / "performance.log", "formatter": "performance", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -684,7 +698,7 @@ def connect( "requests": { "level": "DEBUG", "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "requests.log", + "filename": LOGGING_DIR / "requests.log", "formatter": "timestamped", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -825,23 +839,10 @@ def connect( "class": "logging.StreamHandler", "formatter": "db", }, - "celery_console": { - "level": CELERY_LOGLEVEL, - "class": "logging.StreamHandler", - "formatter": "timestamped", - }, - "celery_file": { - "level": CELERY_LOGLEVEL, - "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "celery.log", - "formatter": "verbose", - "maxBytes": 1024 * 1024 * 10, # 10 MB - "backupCount": 10, - }, "django": { "level": LOG_LEVEL, "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "django.log", + "filename": LOGGING_DIR / "django.log", "formatter": "verbose", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -849,7 +850,7 @@ def connect( "project": { "level": LOG_LEVEL, "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / f"{PROJECT_DIRNAME}.log", + "filename": LOGGING_DIR / f"{PROJECT_DIRNAME}.log", "formatter": "verbose", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -857,7 +858,7 @@ def connect( "performance": { "level": "INFO", "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "performance.log", + "filename": LOGGING_DIR / "performance.log", "formatter": "performance", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -865,7 +866,7 @@ def connect( "requests": { "level": "DEBUG", "class": "logging.handlers.RotatingFileHandler", - "filename": Path(LOGGING_DIR) / "requests.log", + "filename": LOGGING_DIR / "requests.log", "formatter": "timestamped", "maxBytes": 1024 * 1024 * 10, # 10 MB "backupCount": 10, @@ -880,7 +881,26 @@ def connect( # enabling saving to database "class": "log_outgoing_requests.handlers.DatabaseOutgoingRequestsHandler", }, - }, + } + | ( # celery dependant handlers + { + "celery_console": { + "level": CELERY_LOGLEVEL, + "class": "logging.StreamHandler", + "formatter": "timestamped", + }, + "celery_file": { + "level": CELERY_LOGLEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": LOGGING_DIR / "celery.log", + "formatter": "verbose", + "maxBytes": 1024 * 1024 * 10, # 10 MB + "backupCount": 10, + }, + } + if find_spec("celery") + else {} + ), "loggers": { "": { "handlers": logging_root_handlers, @@ -930,18 +950,25 @@ def connect( "level": "DEBUG", "propagate": True, }, - "celery": { - "handlers": ["celery_console"] if LOG_STDOUT else ["celery_file"], - "level": CELERY_LOGLEVEL, - "propagate": True, - }, - }, + } + | ( + { + "celery": { + "handlers": ["celery_console"] if LOG_STDOUT else ["celery_file"], + "level": CELERY_LOGLEVEL, + "propagate": True, + }, + } + if find_spec("celery") + else {} + ), } # # AUTH settings - user accounts, passwords, backends... # -AUTH_USER_MODEL = "accounts.User" +if find_spec(f"{PROJECT_DIRNAME}.accounts"): + AUTH_USER_MODEL = "accounts.User" # Password validation # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators @@ -1053,7 +1080,7 @@ def connect( if "GIT_SHA" in os.environ: GIT_SHA = config("GIT_SHA", "", add_to_docs=False) # in docker (build) context, there is no .git directory -elif (Path(BASE_DIR) / ".git").exists(): +elif (BASE_DIR / ".git").exists(): try: import git except ImportError: @@ -1133,6 +1160,7 @@ def connect( default=False, group="Cross-Origin-Resource-Sharing", help_text="allow cross-domain access from any client", + add_to_docs="corsheaders", ) CORS_ALLOWED_ORIGINS = config( "CORS_ALLOWED_ORIGINS", @@ -1143,6 +1171,7 @@ def connect( "explicitly list the allowed origins for cross-domain requests. " "Example: http://localhost:3000,https://some-app.gemeente.nl" ), + add_to_docs="corsheaders", ) CORS_ALLOWED_ORIGIN_REGEXES = config( "CORS_ALLOWED_ORIGIN_REGEXES", @@ -1150,7 +1179,9 @@ def connect( default=[], group="Cross-Origin-Resource-Sharing", help_text="same as ``CORS_ALLOWED_ORIGINS``, but supports regular expressions", + add_to_docs="corsheaders", ) + # Authorization is included in default_cors_headers CORS_ALLOW_HEADERS = ( list(default_cors_headers) @@ -1168,8 +1199,10 @@ def connect( "By default, Authorization, Accept-Crs and Content-Crs are already included. " "The value of this variable is added to these already included headers." ), + add_to_docs="corsheaders", ) ) + CORS_EXPOSE_HEADERS = [ "content-crs", ] @@ -1190,7 +1223,7 @@ def connect( # # DJANGO-PRIVATES -- safely serve files after authorization # -PRIVATE_MEDIA_ROOT = Path(BASE_DIR) / "private-media" +PRIVATE_MEDIA_ROOT = BASE_DIR / "private-media" PRIVATE_MEDIA_URL = "/private-media/" @@ -1392,54 +1425,72 @@ def connect( def get_content_security_policy(): # ideally we'd use BASE_URI but it'd have to be lazy or cause issues - csp_default_src = [SELF] + config( + extra_default_src = config( "CSP_EXTRA_DEFAULT_SRC", default=[], split=True, group="Content Security Policy", help_text="Extra default source URLs for CSP other than ``self``. Used for ``img-src``, ``style-src`` and ``script-src``.", + add_to_docs="csp", + ) + extra_form_action = config( + "CSP_EXTRA_FORM_ACTION", + default=[], + split=True, + group="Content Security Policy", + help_text="Additional `form-action` sources.", + add_to_docs="csp", ) + form_action = config( + "CSP_FORM_ACTION", + default=["\"'self'\""] + extra_form_action, + split=True, + group="Content Security Policy", + help_text="Override the default `form-action` sources.", + add_to_docs="csp", + ) + extra_img_src = config( + "CSP_EXTRA_IMG_SRC", + default=[], + split=True, + group="Content Security Policy", + help_text="Extra `img-src` sources.", + add_to_docs="csp", + ) + object_src = config( + "CSP_OBJECT_SRC", + default=["\"'none'\""], + split=True, + group="Content Security Policy", + help_text="`object-src` sources.", + add_to_docs="csp", + ) + report_uri = config( + "CSP_REPORT_URI", + None, + group="Content Security Policy", + help_text="URI for CSP report-uri directive.", + add_to_docs="csp", + ) + report_percentage = config( + "CSP_REPORT_PERCENTAGE", + 0.0, + group="Content Security Policy", + help_text="Fraction (between 0 and 1) of requests to include report-uri directive.", + add_to_docs="csp", + ) + + if not csp_installed: + return {} + + csp_default_src = [SELF] + extra_default_src return { "DIRECTIVES": { - "default-src": [SELF] - + config( - "CSP_EXTRA_DEFAULT_SRC", - default=[], - split=True, - group="Content Security Policy", - help_text="Extra default source URLs for CSP other than ``self``. Used for ``img-src``, ``style-src`` and ``script-src``.", - ), - "form-action": config( - "CSP_FORM_ACTION", - default=["\"'self'\""] - + config( - "CSP_EXTRA_FORM_ACTION", - default=[], - split=True, - group="Content Security Policy", - help_text="Additional `form-action` sources.", - ), - split=True, - group="Content Security Policy", - help_text="Override the default `form-action` sources.", - ) - + CORS_ALLOWED_ORIGINS, - "img-src": csp_default_src - + ["data:", "cdn.redoc.ly"] - + config( - "CSP_EXTRA_IMG_SRC", - default=[], - split=True, - group="Content Security Policy", - help_text="Extra `img-src` sources.", - ), - "object-src": config( - "CSP_OBJECT_SRC", - default=["\"'none'\""], - split=True, - group="Content Security Policy", - help_text="`object-src` sources.", - ), + "default-src": csp_default_src, + "form-action": form_action + + CORS_ALLOWED_ORIGINS, # XXX: not passed as default to prevent misconfig?? + "img-src": csp_default_src + ["data:", "cdn.redoc.ly"] + extra_img_src, + "object-src": object_src, "style-src": csp_default_src + [NONCE, "'unsafe-inline'", "fonts.googleapis.com"], "script-src": csp_default_src + [NONCE, "'unsafe-inline'"], @@ -1449,22 +1500,11 @@ def get_content_security_policy(): "frame-ancestors": [NONE], "frame-src": [SELF], "upgrade-insecure-requests": False, # Enable only in production - "report-uri": config( - "CSP_REPORT_URI", - None, - group="Content Security Policy", - help_text="URI for CSP report-uri directive.", - ), + "report-uri": report_uri, }, # Envvar used for django-csp==3.8 was a float between 0 and 1, while django-csp==4.0 # expects a percentage (between 0 and 100) - "REPORT_PERCENTAGE": config( - "CSP_REPORT_PERCENTAGE", - 0.0, - group="Content Security Policy", - help_text="Fraction (between 0 and 1) of requests to include report-uri directive.", - ) - * 100, + "REPORT_PERCENTAGE": report_percentage * 100, } diff --git a/open_api_framework/conf/utils.py b/open_api_framework/conf/utils.py index abea579..4f54636 100644 --- a/open_api_framework/conf/utils.py +++ b/open_api_framework/conf/utils.py @@ -1,12 +1,14 @@ import logging # noqa: TID251 import sys from dataclasses import dataclass +from importlib.util import find_spec from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, TypeVar, assert_never from urllib.parse import urlparse +from warnings import warn from decouple import Csv, Undefined, config as _config, undefined -from sentry_sdk.integrations import DidNotEnable, django, redis +from sentry_sdk.integrations import DidNotEnable, Integration, django, redis from sentry_sdk.integrations.logging import LoggingIntegration @@ -30,17 +32,19 @@ def __eq__(self, other): ENVVAR_REGISTRY = [] +_T = TypeVar("_T") + def config( option: str, - default: Any = undefined, + default: _T = undefined, help_text="", group=None, - add_to_docs=True, + add_to_docs: str | bool = True, auto_display_default=True, *args, **kwargs, -): +) -> _T: """ An override of ``decouple.config``, with custom options to construct documentation for environment variables. @@ -59,18 +63,21 @@ def config( :param help_text: The help text to be displayed for this variable in the documentation. Default `""` :param group: The name of the section under which this variable will be grouped. Default ``None`` :param add_to_docs: Whether or not this variable will be displayed in the documentation. Default ``True`` + If a string is passed, it will only be displayed if it is importable as a module, + and will raise a Warning when it is still passed in from the environment. :param auto_display_default: Whether or not the passed ``default`` value is displayed in the docs, this can be set to ``False`` in case a default needs more explanation that can be added to the ``help_text`` (e.g. if it is computed or based on another variable). Default ``True`` """ - if add_to_docs: - variable = EnvironmentVariable( - name=option, - default=default, - help_text=help_text, - group=group, - auto_display_default=auto_display_default, - ) + variable = EnvironmentVariable( + name=option, + default=default, + help_text=help_text, + group=group, + auto_display_default=auto_display_default, + ) + + def document(): if variable not in ENVVAR_REGISTRY: ENVVAR_REGISTRY.append(variable) else: @@ -85,19 +92,44 @@ def config( if default is not undefined and default is not None: kwargs.setdefault("cast", type(default)) - return _config(option, default=default, *args, **kwargs) + + value = _config(option, default=default, *args, **kwargs) + + match add_to_docs: + case str(module) if find_spec(module): + document() + case str(module): + if value is not default: + warn( + f"{variable.name} found, but required {add_to_docs} is not installed", + RuntimeWarning, + ) + case True: + document() + case False: + pass + case _: + assert_never(add_to_docs) + + return value # type: ignore -def get_sentry_integrations() -> list: +def importable(*items: str) -> list[str]: + "Return the dotted paths that start from an installed package" + + split_items = (item.split(".") for item in items) + return [".".join(item) for item in split_items if find_spec(item[0])] + + +def get_sentry_integrations() -> list[Integration]: """ Determine which Sentry SDK integrations to enable. """ - default = [ - django.DjangoIntegration(), - redis.RedisIntegration(), - ] extra = [] + if find_spec("redis"): # does not raise DidNotEnable if redis is not installed + extra.append(redis.RedisIntegration()) + try: from sentry_sdk.integrations import celery except DidNotEnable: # happens if the celery import fails by the integration @@ -105,11 +137,7 @@ def get_sentry_integrations() -> list: else: extra.append(celery.CeleryIntegration()) - try: - import structlog # type: ignore # noqa - except ImportError: - pass - else: + if find_spec("structlog"): extra.append( LoggingIntegration( level=logging.INFO, # breadcrumbs @@ -119,7 +147,7 @@ def get_sentry_integrations() -> list: ), ) - return [*default, *extra] + return [django.DjangoIntegration(), *extra] def strip_protocol_from_origin(origin: str) -> str: @@ -131,10 +159,10 @@ def get_project_dirname() -> str: return config("DJANGO_SETTINGS_MODULE", add_to_docs=False).split(".")[0] -def get_django_project_dir() -> str: +def get_django_project_dir() -> Path: # Get the path of the importing module base_dirname = get_project_dirname() - return Path(sys.modules[base_dirname].__file__).parent + return Path(sys.modules[base_dirname].__file__).parent # pyright: ignore[reportArgumentType] def mute_logging(config: dict) -> None: # pragma: no cover diff --git a/testapp/settings.py b/testapp/settings.py index 336deac..3439b7e 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -1,15 +1,19 @@ from pathlib import Path -from open_api_framework.conf.base import ( - CONTENT_SECURITY_POLICY, # noqa - SENTRY_CONFIG, # noqa - SENTRY_DSN, # noqa - get_content_security_policy, # noqa -) +from open_api_framework.conf.base import * # noqa: F403 from open_api_framework.conf.utils import config BASE_DIR = Path(__file__).resolve(strict=True).parent +# base configures logging to (their) BASE_DIR / "log" +# relative testapp location is too different from default-project +del LOGGING # noqa: F821 + +with suppress(NameError): + # base configures redis if package is installed + # CI doens't spinup a redis instance + del CACHES # noqa: F821 + SECRET_KEY = config( "SECRET_KEY", "so-secret-i-cant-believe-you-are-looking-at-this", @@ -45,29 +49,12 @@ } } -INSTALLED_APPS = [ - "django.contrib.contenttypes", - "django.contrib.auth", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.admin", - "open_api_framework", - "sessionprofile", - "testapp", - "rosetta", -] +INSTALLED_APPS += ["rosetta"] +dont_work_with_sqlite = ["mozilla_django_oidc_db", "notifications_api_common"] +for app in dont_work_with_sqlite: + if app in INSTALLED_APPS: + INSTALLED_APPS.pop(INSTALLED_APPS.index(app)) -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "sessionprofile.middleware.SessionProfileMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "csp.contrib.rate_limiting.RateLimitedCSPMiddleware", -] TEMPLATES = [ { diff --git a/tests/test_config_helpers.py b/tests/test_config_helpers.py index dddad0d..dc0f793 100644 --- a/tests/test_config_helpers.py +++ b/tests/test_config_helpers.py @@ -1,4 +1,12 @@ -from open_api_framework.conf.utils import config +import os + +import pytest + +from open_api_framework.conf.utils import ( + ENVVAR_REGISTRY, + config, + get_django_project_dir, +) def test_empty_list_as_default(): @@ -11,3 +19,32 @@ def test_non_empty_list_as_default(): value = config("SOME_TEST_ENVVAR", split=True, default=["foo"], add_to_docs=False) assert value == ["foo"] + + +def test_string_list_from_env(monkeypatch): + monkeypatch.setenv("SOME_TEST_ENVVAR", "foo,bar") + + value = config("SOME_TEST_ENVVAR", split=True, default=["foo"], add_to_docs=False) + + assert value == ["foo", "bar"] + + +def test_it_raises_warning_if_add_to_docs_module_is_not_present(monkeypatch): + monkeypatch.setenv("FOO_TEST_ENVVAR", "value") + with pytest.warns() as warnings: + value = config("FOO_TEST_ENVVAR", default="value", add_to_docs="foo_module") + assert value == "value" + assert not any(var.name == "FOO_TEST_VAR" for var in ENVVAR_REGISTRY) + + # warning mentions key actionable info + assert "FOO_TEST_ENVVAR" in str(warnings[0]) + assert "foo_module" in str(warnings[0]) + + +def test_get_django_project_dir(): + project_path = get_django_project_dir() + assert project_path.parts[-1] == "testapp" + + # still compatible with os.path.join + settings = os.path.join(project_path, "settings.py") + assert os.path.exists(settings) diff --git a/tests/test_csp.py b/tests/test_csp.py index bac899c..b2ef638 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -1,5 +1,12 @@ +from importlib.util import find_spec + +import pytest + import testapp.settings +if not find_spec("csp"): + pytest.skip("no csp installed", allow_module_level=True) + def test_csp_default(client): response = client.get("/dummy/") diff --git a/tests/test_generate_envvar_docs.py b/tests/test_generate_envvar_docs.py index df4dd58..4d9fbc8 100644 --- a/tests/test_generate_envvar_docs.py +++ b/tests/test_generate_envvar_docs.py @@ -1,3 +1,4 @@ +from importlib.util import find_spec from unittest.mock import mock_open, patch from django.core.management import call_command @@ -160,6 +161,8 @@ def test_generate_envvar_docs(): mock_file = mock_open() + extras_installed = bool(find_spec("csp")) + with patch( "open_api_framework.management.commands.generate_envvar_docs.open", mock_file ): @@ -174,4 +177,8 @@ def test_generate_envvar_docs(): # Check the entire content written to the mock file written_content = "".join(call.args[0] for call in handle.write.call_args_list) - assert written_content == EXPECTED_OUTPUT + if extras_installed: + assert written_content == EXPECTED_OUTPUT + else: + assert "Cross-Origin-Resource-Sharing" not in written_content + assert "Content Security Policy" not in written_content diff --git a/tests/test_settings.py b/tests/test_settings.py index 223910d..a3bea2e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,9 +1,13 @@ +from importlib.util import find_spec + from django.conf import settings from django.urls import reverse +import pytest from django_webtest import WebTest +@pytest.mark.skipif(not find_spec("sentry_sdk"), reason="No sentry installed") def test_sentry_settings(): """ test that sentry settings are initialized diff --git a/tox.ini b/tox.ini index a9516a9..4164694 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{312, 313}-django{52} + py{312, 313}-django{52}-{extras,noextras} ruff docs skip_missing_interpreters = true @@ -21,12 +21,12 @@ setenv = extras = tests coverage - cors - commonground - redis - structlog + extras: cors + extras: commonground + extras: redis + extras: structlog + extras: csp deps = - django-csp django-rosetta django-webtest django-upgrade-check