diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d595ca8..840d2f1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,18 +78,13 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-gh-actions + python -m pip install --upgrade tox-uv tox-gh-actions + + - name: Docker compose up + run: docker compose -f docker/docker-compose.yml up -d --wait - name: Tox tests - run: | - REDIS_PRIMARY=$(tests/start_redis.sh) - REDIS_SENTINEL=$(tests/start_redis.sh --sentinel) - CONTAINERS="$REDIS_PRIMARY $REDIS_SENTINEL" - trap "docker stop $CONTAINERS && docker rm $CONTAINERS" EXIT - tests/wait_for_redis.sh $REDIS_PRIMARY 6379 - tests/wait_for_redis.sh $REDIS_SENTINEL 26379 - - tox + run: tox env: DJANGO: ${{ matrix.django-version }} REDIS: ${{ matrix.redis-version }} diff --git a/changelog.d/757.misc b/changelog.d/757.misc new file mode 100644 index 00000000..f2bc350f --- /dev/null +++ b/changelog.d/757.misc @@ -0,0 +1,4 @@ +Speed up tests by using `pytest-xdist` and separating settings on different redis databases. +Dropped `pytest-django` +Using `docker-compose` for setting up redis containers for testing +Use `tox-uv` \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..94089968 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,30 @@ +services: + + redis: + image: redis:latest + container_name: redis-primary + command: redis-server --enable-debug-command yes --protected-mode no + ports: + - 6379:6379 + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 5s + retries: 5 + + sentinel: + image: redis:latest + container_name: redis-sentinel + depends_on: + redis: + condition: service_healthy + entrypoint: "redis-sentinel /redis.conf --port 26379" + ports: + - 26379:26379 + volumes: + - "./sentinel.conf:/redis.conf" + healthcheck: + test: redis-cli -p 26379 ping + interval: 5s + timeout: 5s + retries: 5 \ No newline at end of file diff --git a/docker/sentinel.conf b/docker/sentinel.conf new file mode 100644 index 00000000..f6a1523d --- /dev/null +++ b/docker/sentinel.conf @@ -0,0 +1,4 @@ +sentinel monitor default_service 127.0.0.1 6379 1 +sentinel down-after-milliseconds default_service 3200 +sentinel failover-timeout default_service 10000 +sentinel parallel-syncs default_service 1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 9ba9535e..98dbf56a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -85,20 +85,7 @@ REDIS = [testenv] passenv = CI, GITHUB* commands = - {envpython} -m pytest --cov-report= --ds=settings.sqlite {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_herd {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_json {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_lz4 {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_msgpack {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_sentinel {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_sentinel_opts {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_sharding {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_usock {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_zlib {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_zstd {posargs} - {envpython} -m pytest --cov-append --cov-report= --ds=settings.sqlite_gzip {posargs} - {envpython} -m coverage report - {envpython} -m coverage xml + {envpython} -m pytest -n 4 {posargs} deps = dj42: Django>=4.2,<5.0 @@ -108,9 +95,9 @@ deps = msgpack>=0.6.0 pytest pytest-cov - pytest-django - pytest-pythonpath pytest-mock + pytest-pythonpath + pytest-xdist redismaster: https://github.com/redis/redis-py/archive/master.tar.gz lz4>=0.15 pyzstd>=0.15 @@ -119,7 +106,7 @@ deps = basepython = python3 envdir={toxworkdir}/lint commands = - black: black --target-version py36 {posargs:--check --diff} setup.py django_redis/ tests/ + black: black --target-version py38 {posargs:--check --diff} setup.py django_redis/ tests/ ruff: ruff {posargs:check --show-fixes} django_redis/ tests/ mypy: mypy {posargs:--cobertura-xml-report .} django_redis tests deps = @@ -130,24 +117,16 @@ deps = mypy # typing dependencies pytest - pytest-django pytest-mock types-redis skip_install = true [tool:pytest] -DJANGO_SETTINGS_MODULE = settings.sqlite - addopts = --doctest-modules --cov=django_redis --cov-config=setup.cfg --no-cov-on-fail -filterwarnings = - error::DeprecationWarning - error::FutureWarning - error::PendingDeprecationWarning - ignore:.*distutils package is deprecated.*:DeprecationWarning pythonpath = tests testpaths = tests xfail_strict = true @@ -171,6 +150,9 @@ ignore_missing_settings = true [mypy-lz4.frame] ignore_missing_imports = true +[mypy-xdist.scheduler] +ignore_missing_imports = true + [mypy-pyzstd] ignore_missing_imports = true diff --git a/tests/README.rst b/tests/README.rst index aca6871d..f9426a49 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -4,17 +4,4 @@ Running the test suite .. code-block:: bash # start redis and a sentinel (uses docker with image redis:latest) - PRIMARY=$(tests/start_redis.sh) - SENTINEL=$(tests/start_redis.sh --sentinel) - - # or just wait 5 - 10 seconds and most likely this would be the case - tests/wait_for_redis.sh $PRIMARY 6379 - tests/wait_for_redis.sh $SENTINEL 26379 - - # run the tests - tox - - # shut down redis - for container in $PRIMARY $SENTINEL; do - docker stop $container && docker rm $container - done +docker compose -f docker/docker-compose.yml up -d --wait \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 59ea7d8d..e3fec3e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,68 @@ +import sys +from os import environ +from pathlib import Path from typing import Iterable import pytest -from django.core.cache import cache as default_cache +from xdist.scheduler import LoadScopeScheduling from django_redis.cache import BaseCache +from tests.settings_wrapper import SettingsWrapper -@pytest.fixture -def cache() -> Iterable[BaseCache]: +class FixtureScheduling(LoadScopeScheduling): + """Split by [] value. This is very hackish and might blow up any time!""" + + def _split_scope(self, nodeid): + if "[sqlite" in nodeid: + return nodeid.rsplit("[")[-1].replace("]", "") + return None + + +def pytest_xdist_make_scheduler(log, config): + return FixtureScheduling(config, log) + + +def pytest_configure(config): + sys.path.insert(0, str(Path(__file__).absolute().parent)) + + +@pytest.fixture() +def settings(): + """A Django settings object which restores changes after the testrun""" + wrapper = SettingsWrapper() + yield wrapper + wrapper.finalize() + + +@pytest.fixture() +def cache(cache_settings: str) -> Iterable[BaseCache]: + from django import setup + + environ["DJANGO_SETTINGS_MODULE"] = f"settings.{cache_settings}" + setup() + + from django.core.cache import cache as default_cache + yield default_cache default_cache.clear() + + +def pytest_generate_tests(metafunc): + if "cache" in metafunc.fixturenames or "session" in metafunc.fixturenames: + # Mark + settings = [ + "sqlite", + "sqlite_gzip", + "sqlite_herd", + "sqlite_json", + "sqlite_lz4", + "sqlite_msgpack", + "sqlite_sentinel", + "sqlite_sentinel_opts", + "sqlite_sharding", + "sqlite_usock", + "sqlite_zlib", + "sqlite_zstd", + ] + metafunc.parametrize("cache_settings", settings) diff --git a/tests/settings/sqlite_gzip.py b/tests/settings/sqlite_gzip.py index 7ebb1580..69e6122d 100644 --- a/tests/settings/sqlite_gzip.py +++ b/tests/settings/sqlite_gzip.py @@ -3,7 +3,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "LOCATION": ["redis://127.0.0.1:6379?db=2", "redis://127.0.0.1:6379?db=2"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", @@ -11,7 +11,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=2", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", @@ -19,7 +19,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=2,redis://127.0.0.1:6379?db=2", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", @@ -27,7 +27,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=2", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.gzip.GzipCompressor", diff --git a/tests/settings/sqlite_herd.py b/tests/settings/sqlite_herd.py index 9bac3a4c..b411bc4e 100644 --- a/tests/settings/sqlite_herd.py +++ b/tests/settings/sqlite_herd.py @@ -3,22 +3,22 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=5"], + "LOCATION": ["redis://127.0.0.1:6379?db=3"], "OPTIONS": {"CLIENT_CLASS": "django_redis.client.HerdClient"}, }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=3", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.HerdClient"}, }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=3,redis://127.0.0.1:6379?db=3", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.HerdClient"}, }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=3", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.HerdClient"}, "KEY_PREFIX": "test-prefix", }, diff --git a/tests/settings/sqlite_json.py b/tests/settings/sqlite_json.py index 446c09c4..0fc7944e 100644 --- a/tests/settings/sqlite_json.py +++ b/tests/settings/sqlite_json.py @@ -3,7 +3,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "LOCATION": ["redis://127.0.0.1:6379?db=4", "redis://127.0.0.1:6379?db=4"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.json.JSONSerializer", @@ -11,7 +11,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=4", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.json.JSONSerializer", @@ -19,7 +19,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=4,redis://127.0.0.1:6379?db=4", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.json.JSONSerializer", @@ -27,7 +27,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=4", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.json.JSONSerializer", diff --git a/tests/settings/sqlite_lz4.py b/tests/settings/sqlite_lz4.py index 250b7b60..61e3de42 100644 --- a/tests/settings/sqlite_lz4.py +++ b/tests/settings/sqlite_lz4.py @@ -3,7 +3,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "LOCATION": ["redis://127.0.0.1:6379?db=5", "redis://127.0.0.1:6379?db=5"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.lz4.Lz4Compressor", @@ -11,7 +11,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=5", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.lz4.Lz4Compressor", @@ -19,7 +19,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "127.0.0.1:6379?db=1,127.0.0.1:6379?db=1", + "LOCATION": "127.0.0.1:6379?db=5,127.0.0.1:6379?db=5", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.lz4.Lz4Compressor", @@ -27,7 +27,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=5", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.lz4.Lz4Compressor", diff --git a/tests/settings/sqlite_msgpack.py b/tests/settings/sqlite_msgpack.py index 83f8501c..e321550f 100644 --- a/tests/settings/sqlite_msgpack.py +++ b/tests/settings/sqlite_msgpack.py @@ -3,7 +3,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "LOCATION": ["redis://127.0.0.1:6379?db=6", "redis://127.0.0.1:6379?db=6"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.msgpack.MSGPackSerializer", @@ -11,7 +11,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=6", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.msgpack.MSGPackSerializer", @@ -19,7 +19,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=6,redis://127.0.0.1:6379?db=6", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.msgpack.MSGPackSerializer", @@ -27,7 +27,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=6", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SERIALIZER": "django_redis.serializers.msgpack.MSGPackSerializer", diff --git a/tests/settings/sqlite_sentinel.py b/tests/settings/sqlite_sentinel.py index fc59a9ed..f82e7559 100644 --- a/tests/settings/sqlite_sentinel.py +++ b/tests/settings/sqlite_sentinel.py @@ -7,7 +7,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://default_service?db=5"], + "LOCATION": ["redis://127.0.0.1?db=7"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SENTINELS": SENTINELS, @@ -15,7 +15,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://missing_service?db=1", + "LOCATION": "redis://missing_service?db=7", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SENTINELS": SENTINELS, @@ -23,7 +23,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://default_service?db=1", + "LOCATION": "redis://127.0.0.1?db=7", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.SentinelClient", "SENTINELS": SENTINELS, @@ -31,7 +31,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://default_service?db=1", + "LOCATION": "redis://127.0.0.1?db=7", "KEY_PREFIX": "test-prefix", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", diff --git a/tests/settings/sqlite_sentinel_opts.py b/tests/settings/sqlite_sentinel_opts.py index 29f079ed..29cbbed6 100644 --- a/tests/settings/sqlite_sentinel_opts.py +++ b/tests/settings/sqlite_sentinel_opts.py @@ -7,7 +7,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://default_service?db=5"], + "LOCATION": ["redis://default_service?db=8"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SENTINELS": SENTINELS, @@ -16,7 +16,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://missing_service?db=1", + "LOCATION": "redis://missing_service?db=8", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "SENTINELS": SENTINELS, @@ -25,7 +25,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://default_service?db=1", + "LOCATION": "redis://default_service?db=8", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.SentinelClient", "SENTINELS": SENTINELS, @@ -34,7 +34,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://default_service?db=1", + "LOCATION": "redis://default_service?db=8", "KEY_PREFIX": "test-prefix", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", diff --git a/tests/settings/sqlite_sharding.py b/tests/settings/sqlite_sharding.py index 69ef91ec..ba0ca5bd 100644 --- a/tests/settings/sqlite_sharding.py +++ b/tests/settings/sqlite_sharding.py @@ -3,22 +3,22 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=2"], + "LOCATION": ["redis://127.0.0.1:6379?db=9", "redis://127.0.0.1:6379?db=10"], "OPTIONS": {"CLIENT_CLASS": "django_redis.client.ShardClient"}, }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:56379?db=1", "redis://127.0.0.1:56379?db=2"], + "LOCATION": ["redis://127.0.0.1:56379?db=9", "redis://127.0.0.1:56379?db=10"], "OPTIONS": {"CLIENT_CLASS": "django_redis.client.ShardClient"}, }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=9,redis://127.0.0.1:6379?db=9", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.ShardClient"}, }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=9", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.ShardClient"}, "KEY_PREFIX": "test-prefix", }, diff --git a/tests/settings/sqlite_usock.py b/tests/settings/sqlite_usock.py index 64efaba9..084d7dc3 100644 --- a/tests/settings/sqlite_usock.py +++ b/tests/settings/sqlite_usock.py @@ -3,22 +3,22 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["unix:///tmp/redis.sock?db=1", "unix:///tmp/redis.sock?db=1"], + "LOCATION": ["unix:///tmp/redis.sock?db=11", "unix:///tmp/redis.sock?db=11"], "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=11", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=11,redis://127.0.0.1:6379?db=11", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=11", "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, "KEY_PREFIX": "test-prefix", }, diff --git a/tests/settings/sqlite_zlib.py b/tests/settings/sqlite_zlib.py index e5fbf6f9..91faa4de 100644 --- a/tests/settings/sqlite_zlib.py +++ b/tests/settings/sqlite_zlib.py @@ -3,7 +3,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "LOCATION": ["redis://127.0.0.1:6379?db=12", "redis://127.0.0.1:6379?db=12"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", @@ -11,7 +11,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=12", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", @@ -19,7 +19,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=12,redis://127.0.0.1:6379?db=12", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", @@ -27,7 +27,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=12", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", diff --git a/tests/settings/sqlite_zstd.py b/tests/settings/sqlite_zstd.py index 5eff1ba0..819db4d6 100644 --- a/tests/settings/sqlite_zstd.py +++ b/tests/settings/sqlite_zstd.py @@ -3,7 +3,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ["redis://127.0.0.1:6379?db=1", "redis://127.0.0.1:6379?db=1"], + "LOCATION": ["redis://127.0.0.1:6379?db=13", "redis://127.0.0.1:6379?db=13"], "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zstd.ZStdCompressor", @@ -11,7 +11,7 @@ }, "doesnotexist": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:56379?db=1", + "LOCATION": "redis://127.0.0.1:56379?db=13", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zstd.ZStdCompressor", @@ -19,7 +19,7 @@ }, "sample": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1,redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=13,redis://127.0.0.1:6379?db=13", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zstd.ZStdCompressor", @@ -27,7 +27,7 @@ }, "with_prefix": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379?db=1", + "LOCATION": "redis://127.0.0.1:6379?db=13", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "COMPRESSOR": "django_redis.compressors.zstd.ZStdCompressor", diff --git a/tests/settings_wrapper.py b/tests/settings_wrapper.py new file mode 100644 index 00000000..1f41e8b7 --- /dev/null +++ b/tests/settings_wrapper.py @@ -0,0 +1,39 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.test import override_settings + + +class SettingsWrapper: + def __init__(self) -> None: + self._to_restore: list[override_settings] + object.__setattr__(self, "_to_restore", []) + + def __delattr__(self, attr: str) -> None: + from django.test import override_settings + + override = override_settings() + override.enable() + from django.conf import settings + + delattr(settings, attr) + + self._to_restore.append(override) + + def __setattr__(self, attr: str, value) -> None: + from django.test import override_settings + + override = override_settings(**{attr: value}) + override.enable() + self._to_restore.append(override) + + def __getattr__(self, attr: str): + from django.conf import settings + + return getattr(settings, attr) + + def finalize(self) -> None: + for override in reversed(self._to_restore): + override.disable() + + del self._to_restore[:] diff --git a/tests/start_redis.sh b/tests/start_redis.sh deleted file mode 100755 index 00bf2b03..00000000 --- a/tests/start_redis.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash - -# This command will start redis for both the CI and for local testing - -if ! command -v docker &> /dev/null; then - echo >&2 "Docker is required but was not found." - exit 1 -fi - -ARGS=() -PORT=6379 -SENTINEL=0 -while (($# > 0)); do - case "$1" in - --sentinel) - # setup a redis sentinel - CONF=$(mktemp -d) - ARGS=("${ARGS[@]}" "$CONF/redis.conf" --sentinel) - PORT=26379 - SENTINEL=1 - - cat > "$CONF/redis.conf" < str: @pytest.fixture -def ignore_exceptions_cache(settings: SettingsWrapper) -> RedisCache: +def ignore_exceptions_cache(settings) -> RedisCache: caches_setting = copy.deepcopy(settings.CACHES) caches_setting["doesnotexist"]["OPTIONS"]["IGNORE_EXCEPTIONS"] = True caches_setting["doesnotexist"]["OPTIONS"]["LOG_IGNORED_EXCEPTIONS"] = True @@ -54,7 +53,7 @@ def test_get_django_omit_exceptions( ) -def test_get_django_omit_exceptions_priority_1(settings: SettingsWrapper): +def test_get_django_omit_exceptions_priority_1(settings): caches_setting = copy.deepcopy(settings.CACHES) caches_setting["doesnotexist"]["OPTIONS"]["IGNORE_EXCEPTIONS"] = True settings.CACHES = caches_setting @@ -64,7 +63,7 @@ def test_get_django_omit_exceptions_priority_1(settings: SettingsWrapper): assert cache.get("key") is None -def test_get_django_omit_exceptions_priority_2(settings: SettingsWrapper): +def test_get_django_omit_exceptions_priority_2(settings): caches_setting = copy.deepcopy(settings.CACHES) caches_setting["doesnotexist"]["OPTIONS"]["IGNORE_EXCEPTIONS"] = False settings.CACHES = caches_setting @@ -76,9 +75,7 @@ def test_get_django_omit_exceptions_priority_2(settings: SettingsWrapper): @pytest.fixture -def key_prefix_cache( - cache: RedisCache, settings: SettingsWrapper -) -> Iterable[RedisCache]: +def key_prefix_cache(cache: RedisCache, settings) -> Iterable[RedisCache]: caches_setting = copy.deepcopy(settings.CACHES) caches_setting["default"]["KEY_PREFIX"] = "*" settings.CACHES = caches_setting @@ -120,7 +117,7 @@ def test_keys(self, key_prefix_cache: RedisCache, with_prefix_cache: RedisCache) assert "b" not in keys -def test_custom_key_function(cache: RedisCache, settings: SettingsWrapper): +def test_custom_key_function(cache: RedisCache, settings): caches_setting = copy.deepcopy(settings.CACHES) caches_setting["default"]["KEY_FUNCTION"] = "test_cache_options.make_key" caches_setting["default"]["REVERSE_KEY_FUNCTION"] = "test_cache_options.reverse_key" diff --git a/tests/test_client.py b/tests/test_client.py index 74308657..5d49bcab 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,14 +3,15 @@ import pytest from django.core.cache import DEFAULT_CACHE_ALIAS -from pytest_django.fixtures import SettingsWrapper +from django.test import override_settings from pytest_mock import MockerFixture from django_redis.cache import RedisCache from django_redis.client import DefaultClient, ShardClient +from tests.settings_wrapper import SettingsWrapper -@pytest.fixture +@pytest.fixture() def cache_client(cache: RedisCache) -> Iterable[DefaultClient]: client = cache.client client.set("TestClientClose", 0) @@ -22,6 +23,7 @@ class TestClientClose: def test_close_client_disconnect_default( self, cache_client: DefaultClient, mocker: MockerFixture ): + cache_client._options.clear() mock = mocker.patch.object(cache_client.connection_factory, "disconnect") cache_client.close() assert not mock.called @@ -32,10 +34,10 @@ def test_close_disconnect_settings( settings: SettingsWrapper, mocker: MockerFixture, ): - settings.DJANGO_REDIS_CLOSE_CONNECTION = True - mock = mocker.patch.object(cache_client.connection_factory, "disconnect") - cache_client.close() - assert mock.called + with override_settings(DJANGO_REDIS_CLOSE_CONNECTION=True): + mock = mocker.patch.object(cache_client.connection_factory, "disconnect") + cache_client.close() + assert mock.called def test_close_disconnect_settings_cache( self, @@ -43,11 +45,13 @@ def test_close_disconnect_settings_cache( mocker: MockerFixture, settings: SettingsWrapper, ): - settings.CACHES[DEFAULT_CACHE_ALIAS]["OPTIONS"]["CLOSE_CONNECTION"] = True - cache_client.set("TestClientClose", 0) - mock = mocker.patch.object(cache_client.connection_factory, "disconnect") - cache_client.close() - assert mock.called + caches = settings.CACHES + caches[DEFAULT_CACHE_ALIAS]["OPTIONS"]["CLOSE_CONNECTION"] = True + with override_settings(CACHES=caches): + cache_client.set("TestClientClose", 0) + mock = mocker.patch.object(cache_client.connection_factory, "disconnect") + cache_client.close() + assert mock.called def test_close_disconnect_client_options( self, cache_client: DefaultClient, mocker: MockerFixture diff --git a/tests/test_session.py b/tests/test_session.py index bcea9c39..e76e0fc0 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,379 +1,386 @@ import base64 -import unittest +from collections import Counter from datetime import timedelta -from typing import Optional, Type +from typing import Iterable import django import pytest -from django.conf import settings -from django.contrib.sessions.backends.base import SessionBase -from django.contrib.sessions.backends.cache import SessionStore as CacheSession -from django.core.cache import DEFAULT_CACHE_ALIAS, caches +from django.contrib.sessions.backends.cache import SessionStore from django.test import override_settings from django.utils import timezone -from django_redis.serializers.msgpack import MSGPackSerializer - -SessionType = Type[SessionBase] - - -# Copied from Django's sessions test suite. Keep in sync with upstream. -# https://github.com/django/django/blob/main/tests/sessions_tests/tests.py -class SessionTestsMixin: - # This does not inherit from TestCase to avoid any tests being run with this - # class, which wouldn't work, and to allow different TestCase subclasses to - # be used. - - backend: Optional[SessionType] = None # subclasses must specify - - def setUp(self): - self.session = self.backend() - - def tearDown(self): - # NB: be careful to delete any sessions created; stale sessions fill up - # the /tmp (with some backends) and eventually overwhelm it after lots - # of runs (think buildbots) - self.session.delete() - - def test_new_session(self): - self.assertIs(self.session.modified, False) - self.assertIs(self.session.accessed, False) - - def test_get_empty(self): - self.assertIsNone(self.session.get("cat")) - - def test_store(self): - self.session["cat"] = "dog" - self.assertIs(self.session.modified, True) - self.assertEqual(self.session.pop("cat"), "dog") - - def test_pop(self): - self.session["some key"] = "exists" - # Need to reset these to pretend we haven't accessed it: - self.accessed = False - self.modified = False - - self.assertEqual(self.session.pop("some key"), "exists") - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, True) - self.assertIsNone(self.session.get("some key")) - - def test_pop_default(self): - self.assertEqual( - self.session.pop("some key", "does not exist"), "does not exist" - ) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, False) - - def test_pop_default_named_argument(self): - self.assertEqual( - self.session.pop("some key", default="does not exist"), "does not exist" - ) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, False) - - def test_pop_no_default_keyerror_raised(self): - with self.assertRaises(KeyError): - self.session.pop("some key") - - def test_setdefault(self): - self.assertEqual(self.session.setdefault("foo", "bar"), "bar") - self.assertEqual(self.session.setdefault("foo", "baz"), "bar") - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, True) - - def test_update(self): - self.session.update({"update key": 1}) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, True) - self.assertEqual(self.session.get("update key"), 1) - - def test_has_key(self): - self.session["some key"] = 1 - self.session.modified = False - self.session.accessed = False - self.assertIn("some key", self.session) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, False) - - def test_values(self): - self.assertEqual(list(self.session.values()), []) - self.assertIs(self.session.accessed, True) - self.session["some key"] = 1 - self.session.modified = False - self.session.accessed = False - self.assertEqual(list(self.session.values()), [1]) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, False) - - def test_keys(self): - self.session["x"] = 1 - self.session.modified = False - self.session.accessed = False - self.assertEqual(list(self.session.keys()), ["x"]) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, False) - - def test_items(self): - self.session["x"] = 1 - self.session.modified = False - self.session.accessed = False - self.assertEqual(list(self.session.items()), [("x", 1)]) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, False) - - def test_clear(self): - self.session["x"] = 1 - self.session.modified = False - self.session.accessed = False - self.assertEqual(list(self.session.items()), [("x", 1)]) - self.session.clear() - self.assertEqual(list(self.session.items()), []) - self.assertIs(self.session.accessed, True) - self.assertIs(self.session.modified, True) - - def test_save(self): - self.session.save() - self.assertIs(self.session.exists(self.session.session_key), True) - - def test_delete(self): - self.session.save() - self.session.delete(self.session.session_key) - self.assertIs(self.session.exists(self.session.session_key), False) - - def test_flush(self): - self.session["foo"] = "bar" - self.session.save() - prev_key = self.session.session_key - self.session.flush() - self.assertIs(self.session.exists(prev_key), False) - self.assertNotEqual(self.session.session_key, prev_key) - self.assertIsNone(self.session.session_key) - self.assertIs(self.session.modified, True) - self.assertIs(self.session.accessed, True) - - def test_cycle(self): - self.session["a"], self.session["b"] = "c", "d" - self.session.save() - prev_key = self.session.session_key - prev_data = list(self.session.items()) - self.session.cycle_key() - self.assertIs(self.session.exists(prev_key), False) - self.assertNotEqual(self.session.session_key, prev_key) - self.assertEqual(list(self.session.items()), prev_data) - - def test_cycle_with_no_session_cache(self): - self.session["a"], self.session["b"] = "c", "d" - self.session.save() - prev_data = self.session.items() - self.session = self.backend(self.session.session_key) - self.assertIs(hasattr(self.session, "_session_cache"), False) - self.session.cycle_key() - self.assertCountEqual(self.session.items(), prev_data) - - def test_save_doesnt_clear_data(self): - self.session["a"] = "b" - self.session.save() - self.assertEqual(self.session["a"], "b") - - def test_invalid_key(self): - # Submitting an invalid session key (either by guessing, or if the db has - # removed the key) results in a new key being generated. - try: - session = self.backend("1") - session.save() - self.assertNotEqual(session.session_key, "1") - self.assertIsNone(session.get("cat")) - session.delete() - finally: - # Some backends leave a stale cache entry for the invalid - # session key; make sure that entry is manually deleted - session.delete("1") - def test_session_key_empty_string_invalid(self): - """Falsey values (Such as an empty string) are rejected.""" - self.session._session_key = "" - self.assertIsNone(self.session.session_key) +@pytest.fixture +def session(cache) -> Iterable[SessionStore]: + s = SessionStore() + + yield s + + s.delete() + + +def test_new_session(session): + assert session.modified is False + assert session.accessed is False + + +def test_get_empty(session): + assert session.get("cat") is None + + +def test_store(session): + session["cat"] = "dog" + assert session.modified is True + assert session.pop("cat") == "dog" + + +def test_pop(session): + session["some key"] = "exists" + # Need to reset these to pretend we haven't accessed it: + session.accessed = False + session.modified = False + + assert session.pop("some key") == "exists" + assert session.accessed is True + assert session.modified is True + assert session.get("some key") is None + + +def test_pop_default(session): + assert session.pop("some key", "does not exist") == "does not exist" + assert session.accessed is True + assert session.modified is False + + +def test_pop_default_named_argument(session): + assert session.pop("some key", default="does not exist") == "does not exist" + assert session.accessed is True + assert session.modified is False + + +def test_pop_no_default_keyerror_raised(session): + with pytest.raises(KeyError): + session.pop("some key") + + +def test_setdefault(session): + assert session.setdefault("foo", "bar") == "bar" + assert session.setdefault("foo", "baz") == "bar" + assert session.accessed is True + assert session.modified is True + + +def test_update(session): + session.update({"update key": 1}) + assert session.accessed is True + assert session.modified is True + assert session.get("update key") == 1 + + +def test_has_key(session): + session["some key"] = 1 + session.modified = False + session.accessed = False + assert "some key" in session + assert session.accessed is True + assert session.modified is False + + +def test_values(session): + assert list(session.values()) == [] + assert session.accessed is True + session["some key"] = 1 + session.modified = False + session.accessed = False + assert list(session.values()) == [1] + assert session.accessed is True + assert session.modified is False + + +def test_keys(session): + session["x"] = 1 + session.modified = False + session.accessed = False + assert list(session.keys()) == ["x"] + assert session.accessed is True + assert session.modified is False + + +def test_items(session): + session["x"] = 1 + session.modified = False + session.accessed = False + assert list(session.items()) == [("x", 1)] + assert session.accessed is True + assert session.modified is False + + +def test_clear(session): + session["x"] = 1 + session.modified = False + session.accessed = False + assert list(session.items()) == [("x", 1)] + session.clear() + assert list(session.items()) == [] + assert session.accessed is True + assert session.modified is True + + +def test_save(session): + session.save() + assert session.exists(session.session_key) is True + + +def test_delete(session): + session.save() + session.delete(session.session_key) + assert session.exists(session.session_key) is False + + +def test_flush(session): + session["foo"] = "bar" + session.save() + prev_key = session.session_key + session.flush() + assert session.exists(prev_key) is False + assert session.session_key != prev_key + assert session.session_key is None + assert session.modified is True + assert session.accessed is True - def test_session_key_too_short_invalid(self): - """Strings shorter than 8 characters are rejected.""" - self.session._session_key = "1234567" - self.assertIsNone(self.session.session_key) - def test_session_key_valid_string_saved(self): - """Strings of length 8 and up are accepted and stored.""" - self.session._session_key = "12345678" - self.assertEqual(self.session.session_key, "12345678") +def test_cycle(session): + session["a"], session["b"] = "c", "d" + session.save() + prev_key = session.session_key + prev_data = list(session.items()) + session.cycle_key() + assert session.exists(prev_key) is False + assert session.session_key != prev_key + assert list(session.items()) == prev_data - def test_session_key_is_read_only(self): - def set_session_key(session): - session.session_key = session._get_new_session_key() - with self.assertRaises(AttributeError): - set_session_key(self.session) +def test_cycle_with_no_session_cache(session): + session["a"], session["b"] = "c", "d" + session.save() + prev_data = session.items() + session = SessionStore(session.session_key) + assert hasattr(session, "_session_cache") is False + session.cycle_key() + assert Counter(session.items()) == Counter(prev_data) - # Custom session expiry - def test_default_expiry(self): - # A normal session has a max age equal to settings - self.assertEqual(self.session.get_expiry_age(), settings.SESSION_COOKIE_AGE) - # So does a custom session with an idle expiration time of 0 (but it'll - # expire at browser close) - self.session.set_expiry(0) - self.assertEqual(self.session.get_expiry_age(), settings.SESSION_COOKIE_AGE) +def test_save_doesnt_clear_data(session): + session["a"] = "b" + session.save() + assert session["a"] == "b" - def test_custom_expiry_seconds(self): - modification = timezone.now() - self.session.set_expiry(10) +def test_invalid_key(session): + # Submitting an invalid session key (either by guessing, or if the db has + # removed the key) results in a new key being generated. + try: + session = SessionStore("1") + session.save() + assert session.session_key != "1" + assert session.get("cat") is None + session.delete() + finally: + # Some backends leave a stale cache entry for the invalid + # session key; make sure that entry is manually deleted + session.delete("1") - date = self.session.get_expiry_date(modification=modification) - self.assertEqual(date, modification + timedelta(seconds=10)) - age = self.session.get_expiry_age(modification=modification) - self.assertEqual(age, 10) +def test_session_key_empty_string_invalid(session): + """Falsey values (Such as an empty string) are rejected.""" + session._session_key = "" + assert session.session_key is None - def test_custom_expiry_timedelta(self): - modification = timezone.now() - # Mock timezone.now, because set_expiry calls it on this code path. - original_now = timezone.now +def test_session_key_too_short_invalid(session): + """Strings shorter than 8 characters are rejected.""" + session._session_key = "1234567" + assert session.session_key is None + + +def test_session_key_valid_string_saved(session): + """Strings of length 8 and up are accepted and stored.""" + session._session_key = "12345678" + assert session.session_key == "12345678" + + +def test_session_key_is_read_only(session): + def set_session_key(s): + s.session_key = s._get_new_session_key() + + with pytest.raises(AttributeError): + set_session_key(session) + + +# Custom session expiry +def test_default_expiry(session, settings): + # A normal session has a max age equal to settings + assert session.get_expiry_age() == settings.SESSION_COOKIE_AGE + + # So does a custom session with an idle expiration time of 0 (but it'll + # expire at browser close) + session.set_expiry(0) + assert session.get_expiry_age() == settings.SESSION_COOKIE_AGE + + +def test_custom_expiry_seconds(session): + modification = timezone.now() + + session.set_expiry(10) + + date = session.get_expiry_date(modification=modification) + assert date == modification + timedelta(seconds=10) + + age = session.get_expiry_age(modification=modification) + assert age == 10 + + +def test_custom_expiry_timedelta(session): + modification = timezone.now() + + # Mock timezone.now, because set_expiry calls it on this code path. + original_now = timezone.now + try: + timezone.now = lambda: modification + session.set_expiry(timedelta(seconds=10)) + finally: + timezone.now = original_now + + date = session.get_expiry_date(modification=modification) + assert date == modification + timedelta(seconds=10) + + age = session.get_expiry_age(modification=modification) + assert age == 10 + + +def test_custom_expiry_datetime(session): + modification = timezone.now() + + session.set_expiry(modification + timedelta(seconds=10)) + + date = session.get_expiry_date(modification=modification) + assert date == modification + timedelta(seconds=10) + + age = session.get_expiry_age(modification=modification) + assert age == 10 + + +def test_custom_expiry_reset(session, settings): + session.set_expiry(None) + session.set_expiry(10) + session.set_expiry(None) + assert session.get_expiry_age() == settings.SESSION_COOKIE_AGE + + +def test_get_expire_at_browser_close(session): + # Tests get_expire_at_browser_close with different settings and different + # set_expiry calls + with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=False): + session.set_expiry(10) + assert session.get_expire_at_browser_close() is False + + session.set_expiry(0) + assert session.get_expire_at_browser_close() is True + + session.set_expiry(None) + assert session.get_expire_at_browser_close() is False + + with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=True): + session.set_expiry(10) + assert session.get_expire_at_browser_close() is False + + session.set_expiry(0) + assert session.get_expire_at_browser_close() is True + + session.set_expiry(None) + assert session.get_expire_at_browser_close() is True + + +def test_decode(session): + # Ensure we can decode what we encode + data = {"a test key": "a test value"} + encoded = session.encode(data) + assert session.decode(encoded) == data + + +def test_decode_failure_logged_to_security(session, caplog): + bad_encode = base64.b64encode(b"flaskdj:alkdjf").decode("ascii") + # with self.assertLogs("django.security.SuspiciousSession", "WARNING") as cm: + assert session.decode(bad_encode) == {} + assert ( + "django.security.SuspiciousSession", + 30, + "Session data corrupted", + ) in caplog.record_tuples + + +@pytest.mark.skipif( + django.VERSION >= (4, 2), + reason="PickleSerializer is removed as of https://code.djangoproject.com/ticket/29708", +) +def test_actual_expiry(session): + # this doesn't work with JSONSerializer (serializing timedelta) + with override_settings( + SESSION_SERIALIZER="django.contrib.sessions.serializers.PickleSerializer" + ): + session = SessionStore() # reinitialize after overriding settings + + # Regression test for #19200 + old_session_key = None + new_session_key = None try: - timezone.now = lambda: modification - self.session.set_expiry(timedelta(seconds=10)) + session["foo"] = "bar" + session.set_expiry(-timedelta(seconds=10)) + session.save() + old_session_key = session.session_key + # With an expiry date in the past, the session expires instantly. + new_session = SessionStore(session.session_key) + new_session_key = new_session.session_key + assert "foo" not in new_session finally: - timezone.now = original_now - - date = self.session.get_expiry_date(modification=modification) - self.assertEqual(date, modification + timedelta(seconds=10)) - - age = self.session.get_expiry_age(modification=modification) - self.assertEqual(age, 10) - - def test_custom_expiry_datetime(self): - modification = timezone.now() - - self.session.set_expiry(modification + timedelta(seconds=10)) - - date = self.session.get_expiry_date(modification=modification) - self.assertEqual(date, modification + timedelta(seconds=10)) - - age = self.session.get_expiry_age(modification=modification) - self.assertEqual(age, 10) - - def test_custom_expiry_reset(self): - self.session.set_expiry(None) - self.session.set_expiry(10) - self.session.set_expiry(None) - self.assertEqual(self.session.get_expiry_age(), settings.SESSION_COOKIE_AGE) - - def test_get_expire_at_browser_close(self): - # Tests get_expire_at_browser_close with different settings and different - # set_expiry calls - with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=False): - self.session.set_expiry(10) - self.assertIs(self.session.get_expire_at_browser_close(), False) - - self.session.set_expiry(0) - self.assertIs(self.session.get_expire_at_browser_close(), True) - - self.session.set_expiry(None) - self.assertIs(self.session.get_expire_at_browser_close(), False) - - with override_settings(SESSION_EXPIRE_AT_BROWSER_CLOSE=True): - self.session.set_expiry(10) - self.assertIs(self.session.get_expire_at_browser_close(), False) - - self.session.set_expiry(0) - self.assertIs(self.session.get_expire_at_browser_close(), True) - - self.session.set_expiry(None) - self.assertIs(self.session.get_expire_at_browser_close(), True) - - def test_decode(self): - # Ensure we can decode what we encode - data = {"a test key": "a test value"} - encoded = self.session.encode(data) - self.assertEqual(self.session.decode(encoded), data) - - def test_decode_failure_logged_to_security(self): - bad_encode = base64.b64encode(b"flaskdj:alkdjf").decode("ascii") - with self.assertLogs("django.security.SuspiciousSession", "WARNING") as cm: - self.assertEqual({}, self.session.decode(bad_encode)) - # The failed decode is logged. - self.assertIn("corrupted", cm.output[0]) - - def test_actual_expiry(self): - # this doesn't work with JSONSerializer (serializing timedelta) - with override_settings( - SESSION_SERIALIZER="django.contrib.sessions.serializers.PickleSerializer" - ): - self.session = self.backend() # reinitialize after overriding settings - - # Regression test for #19200 - old_session_key = None - new_session_key = None - try: - self.session["foo"] = "bar" - self.session.set_expiry(-timedelta(seconds=10)) - self.session.save() - old_session_key = self.session.session_key - # With an expiry date in the past, the session expires instantly. - new_session = self.backend(self.session.session_key) - new_session_key = new_session.session_key - self.assertNotIn("foo", new_session) - finally: - self.session.delete(old_session_key) - self.session.delete(new_session_key) - - def test_session_load_does_not_create_record(self): - """ - Loading an unknown session key does not create a session record. - Creating session records on load is a DOS vulnerability. - """ - session = self.backend("someunknownkey") - session.load() - - self.assertIsNone(session.session_key) - self.assertIs(session.exists(session.session_key), False) - # provided unknown key was cycled, not reused - self.assertNotEqual(session.session_key, "someunknownkey") - - def test_session_save_does_not_resurrect_session_logged_out_in_other_context(self): - """ - Sessions shouldn't be resurrected by a concurrent request. - """ - from django.contrib.sessions.backends.base import UpdateError - - # Create new session. - s1 = self.backend() - s1["test_data"] = "value1" - s1.save(must_create=True) - - # Logout in another context. - s2 = self.backend(s1.session_key) - s2.delete() - - # Modify session in first context. - s1["test_data"] = "value2" - with self.assertRaises(UpdateError): - # This should throw an exception as the session is deleted, not - # resurrect the session. - s1.save() - - self.assertEqual(s1.load(), {}) - - -class SessionTests(SessionTestsMixin, unittest.TestCase): - backend = CacheSession - - @pytest.mark.skipif( - django.VERSION >= (4, 2), - reason="PickleSerializer is removed as of https://code.djangoproject.com/ticket/29708", - ) - def test_actual_expiry(self): - if isinstance( - caches[DEFAULT_CACHE_ALIAS].client._serializer, MSGPackSerializer - ): - self.skipTest("msgpack serializer doesn't support datetime serialization") - super().test_actual_expiry() + session.delete(old_session_key) + session.delete(new_session_key) + + +def test_session_load_does_not_create_record(session): + """ + Loading an unknown session key does not create a session record. + Creating session records on load is a DOS vulnerability. + """ + session = SessionStore("someunknownkey") + session.load() + + assert session.session_key is None + assert session.exists(session.session_key) is False + # provided unknown key was cycled, not reused + assert session.session_key != "someunknownkey" + + +def test_session_save_does_not_resurrect_session_logged_out_in_other_context(session): + """ + Sessions shouldn't be resurrected by a concurrent request. + """ + from django.contrib.sessions.backends.base import UpdateError + + # Create new session. + s1 = SessionStore() + s1["test_data"] = "value1" + s1.save(must_create=True) + + # Logout in another context. + s2 = SessionStore(s1.session_key) + s2.delete() + + # Modify session in first context. + s1["test_data"] = "value2" + with pytest.raises(UpdateError): + # This should throw an exception as the session is deleted, not + # resurrect the session. + s1.save() + + assert s1.load() == {} diff --git a/tests/wait_for_redis.sh b/tests/wait_for_redis.sh deleted file mode 100755 index 97ab0987..00000000 --- a/tests/wait_for_redis.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -CONTAINER=$1 -PORT=$2 - -for i in {1..60}; do - if docker inspect "$CONTAINER" \ - --format '{{.State.Health.Status}}' \ - | grep -q starting; then - sleep 1 - else - if ! nc -z 127.0.0.1 $PORT &>/dev/null; then - echo >&2 "Port $PORT does not seem to be open, redis will not work with docker rootless!" - fi - # exit successfully in case nc was not found or -z is not supported - exit 0 - fi -done - -echo >&2 "Redis did not seem to start in ~60s, aborting" -exit 1