Update Dockerfile and settings for user permissions, add test URL patterns, and adjust volume paths

This commit is contained in:
Joakim Hellsén 2026-02-15 20:52:08 +01:00
commit b08143980c
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
5 changed files with 219 additions and 4 deletions

View file

@ -25,6 +25,7 @@
"elif", "elif",
"excinfo", "excinfo",
"Facepunch", "Facepunch",
"falsey",
"Feedly", "Feedly",
"filterwarnings", "filterwarnings",
"forloop", "forloop",
@ -63,6 +64,7 @@
"regularuser", "regularuser",
"rewardcampaign", "rewardcampaign",
"runserver", "runserver",
"s3cret",
"sendgrid", "sendgrid",
"sitelinks", "sitelinks",
"sitewide", "sitewide",
@ -73,6 +75,7 @@
"thelovinator", "thelovinator",
"timebaseddrop", "timebaseddrop",
"tqdm", "tqdm",
"trixie",
"ttvdrops", "ttvdrops",
"twid", "twid",
"twitchgamedata", "twitchgamedata",

View file

@ -15,10 +15,16 @@ RUN chmod +x /app/start.sh
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
RUN groupadd -g 1000 ttvdrops \
&& useradd -m -u 1000 -g 1000 -d /home/ttvdrops -s /bin/sh ttvdrops \
&& mkdir -p /home/ttvdrops/.local/share/TTVDrops \
&& chown -R ttvdrops:ttvdrops /home/ttvdrops /app
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/ || exit 1 CMD curl -f http://localhost:8000/ || exit 1
VOLUME ["/root/.local/share/TTVDrops"] VOLUME ["/home/ttvdrops/.local/share/TTVDrops"]
EXPOSE 8000 EXPOSE 8000
USER ttvdrops
ENTRYPOINT [ "/app/start.sh" ] ENTRYPOINT [ "/app/start.sh" ]
CMD ["uv", "run", "gunicorn", "config.wsgi:application", "--workers", "27", "--bind", "0.0.0.0:8000"] CMD ["uv", "run", "gunicorn", "config.wsgi:application", "--workers", "27", "--bind", "0.0.0.0:8000"]

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib import importlib
import os import os
import sys
from contextlib import contextmanager from contextlib import contextmanager
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -50,12 +51,21 @@ def reload_settings_module() -> Generator[Callable[..., ModuleType]]:
env[key] = value env[key] = value
with temporary_env(env): with temporary_env(env):
return importlib.reload(settings) # If another test removed the module from sys.modules (we do that in a
# few tests to simulate startup failure), importlib.reload() will
# raise. Import afresh in that case so the fixture is resilient.
if "config.settings" in sys.modules:
return importlib.reload(sys.modules["config.settings"])
return importlib.import_module("config.settings")
yield _reload yield _reload
with temporary_env(original_env): with temporary_env(original_env):
importlib.reload(settings) # Ensure `config.settings` is restored even if some tests removed the
# module from sys.modules (several tests intentionally pop it). Use
# importlib.import_module to (re)import and then reload to execute
# module-level initialization under the original environment.
importlib.reload(importlib.import_module("config.settings"))
def test_env_bool_truthy_values(monkeypatch: pytest.MonkeyPatch) -> None: def test_env_bool_truthy_values(monkeypatch: pytest.MonkeyPatch) -> None:
@ -134,3 +144,149 @@ def test_sessions_app_installed() -> None:
def test_session_table_exists() -> None: def test_session_table_exists() -> None:
"""The sessions table should be available in the database.""" """The sessions table should be available in the database."""
Session.objects.count() Session.objects.count()
def test_env_bool_falsey_values(monkeypatch: pytest.MonkeyPatch) -> None:
"""env_bool should treat common falsey strings as False."""
falsy_values: list[str] = ["0", "false", "no", "n", "off", "", " "]
for value in falsy_values:
monkeypatch.setenv("FEATURE_FLAG", value)
assert settings.env_bool("FEATURE_FLAG") is False
def test_env_int_invalid_raises_value_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""env_int should raise ValueError for non-integer strings."""
monkeypatch.setenv("MAX_COUNT", "not-an-int")
with pytest.raises(ValueError, match="invalid literal for int"):
settings.env_int("MAX_COUNT", 1)
def test_testing_true_when_sys_argv_contains_test(
monkeypatch: pytest.MonkeyPatch,
reload_settings_module: Callable[..., ModuleType],
) -> None:
"""TESTING should be true when 'test' is present in sys.argv."""
monkeypatch.setattr("sys.argv", ["manage.py", "test"])
reloaded: ModuleType = reload_settings_module()
assert reloaded.TESTING is True
def test_testing_true_when_pytest_version_set(reload_settings_module: Callable[..., ModuleType]) -> None:
"""TESTING should be true when PYTEST_VERSION is set in the env."""
reloaded: ModuleType = reload_settings_module(PYTEST_VERSION="7.0.0")
assert reloaded.TESTING is True
def test_media_and_static_directories_created_on_import(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
reload_settings_module: Callable[..., ModuleType],
) -> None:
"""Importing settings should create MEDIA_ROOT and STATIC_ROOT under DATA_DIR."""
fake_dir: Path = tmp_path / "app_data"
def fake_user_data_dir(**_: str) -> str:
fake_dir.mkdir(parents=True, exist_ok=True)
return str(fake_dir)
monkeypatch.setattr("platformdirs.user_data_dir", fake_user_data_dir)
reloaded: ModuleType = reload_settings_module()
assert fake_dir == reloaded.DATA_DIR
assert reloaded.MEDIA_ROOT.exists() is True
assert reloaded.STATIC_ROOT.exists() is True
def test_missing_secret_key_causes_system_exit(monkeypatch: pytest.MonkeyPatch) -> None:
"""Missing DJANGO_SECRET_KEY should abort startup with SystemExit."""
monkeypatch.delenv("DJANGO_SECRET_KEY", raising=False)
# Prevent load_dotenv from repopulating values from the repository .env file
monkeypatch.setattr("dotenv.load_dotenv", lambda *a, **k: None)
# Remove the cached module so the import executes module-level code freshly
sys.modules.pop("config.settings", None)
with pytest.raises(SystemExit):
__import__("config.settings")
def test_email_settings_from_env(reload_settings_module: Callable[..., ModuleType]) -> None:
"""EMAIL_* values should be read from the environment and cast correctly."""
reloaded: ModuleType = reload_settings_module(
EMAIL_HOST="smtp.example.com",
EMAIL_PORT="1025",
EMAIL_USE_TLS="0",
EMAIL_USE_SSL="1",
EMAIL_HOST_USER="me@example.com",
EMAIL_HOST_PASSWORD="s3cret",
EMAIL_TIMEOUT="3",
)
assert reloaded.EMAIL_HOST == "smtp.example.com"
assert reloaded.EMAIL_PORT == 1025
assert reloaded.EMAIL_USE_TLS is False
assert reloaded.EMAIL_USE_SSL is True
assert reloaded.EMAIL_HOST_USER == "me@example.com"
assert reloaded.EMAIL_HOST_PASSWORD == "s3cret"
assert reloaded.EMAIL_TIMEOUT == 3
# DEFAULT_FROM_EMAIL and SERVER_EMAIL mirror EMAIL_HOST_USER in settings.py
assert reloaded.DEFAULT_FROM_EMAIL == "me@example.com"
assert reloaded.SERVER_EMAIL == "me@example.com"
def test_database_settings_when_not_testing(
monkeypatch: pytest.MonkeyPatch,
reload_settings_module: Callable[..., ModuleType],
) -> None:
"""When not running tests, DATABASES should use the Postgres configuration."""
# Ensure the module believes it's not running tests
monkeypatch.setattr("sys.argv", ["manage.py", "runserver"])
monkeypatch.delenv("PYTEST_VERSION", raising=False)
reloaded: ModuleType = reload_settings_module(
POSTGRES_DB="prod_db",
POSTGRES_USER="prod_user",
POSTGRES_PASSWORD="secret",
POSTGRES_HOST="db.host",
POSTGRES_PORT="5433",
CONN_MAX_AGE="120",
CONN_HEALTH_CHECKS="0",
)
assert reloaded.TESTING is False
db_cfg = reloaded.DATABASES["default"]
assert db_cfg["ENGINE"] == "django.db.backends.postgresql"
assert db_cfg["NAME"] == "prod_db"
assert db_cfg["USER"] == "prod_user"
assert db_cfg["PASSWORD"] == "secret"
assert db_cfg["HOST"] == "db.host"
assert db_cfg["PORT"] == 5433
assert db_cfg["CONN_MAX_AGE"] == 120
assert db_cfg["CONN_HEALTH_CHECKS"] is False
def test_debug_tools_installed_only_when_not_testing(
monkeypatch: pytest.MonkeyPatch,
reload_settings_module: Callable[..., ModuleType],
) -> None:
"""`debug_toolbar` and `silk` are only added when not TESTING."""
# Not testing -> tools should be present
monkeypatch.setattr("sys.argv", ["manage.py", "runserver"])
monkeypatch.delenv("PYTEST_VERSION", raising=False)
not_testing: ModuleType = reload_settings_module()
assert "debug_toolbar" in not_testing.INSTALLED_APPS
assert "silk" in not_testing.INSTALLED_APPS
# Testing -> tools should not be present
testing: ModuleType = reload_settings_module(PYTEST_VERSION="7.0.0")
assert "debug_toolbar" not in testing.INSTALLED_APPS
assert "silk" not in testing.INSTALLED_APPS
def test_logging_configuration_structure() -> None:
"""Basic logging structure and levels should be present in LOGGING."""
assert settings.LOGGING["handlers"]["console"]["class"] == "logging.StreamHandler"
assert settings.LOGGING["loggers"]["ttvdrops"]["level"] == "DEBUG"
assert settings.LOGGING["loggers"][""]["level"] == "INFO"

50
config/tests/test_urls.py Normal file
View file

@ -0,0 +1,50 @@
from __future__ import annotations
import importlib
from typing import TYPE_CHECKING
from django.test.utils import override_settings
from django.urls import reverse
if TYPE_CHECKING:
from collections.abc import Iterable
from types import ModuleType
def _pattern_strings(module: ModuleType) -> Iterable[str]:
"""Return string representations of the URL patterns' route/regex."""
return (str(p.pattern) for p in module.urlpatterns)
def _reload_urls_with(**overrides) -> ModuleType:
"""Reload `config.urls` while temporarily overriding Django settings.
Returns:
ModuleType: The `config.urls` module as imported under the
overridden settings. The real `config.urls` module is reloaded
back to the test-default settings before this function returns.
"""
# Import under overridden settings
with override_settings(**overrides):
mod = importlib.reload(importlib.import_module("config.urls"))
# Restore to the normal test settings to avoid leaking changes to other tests
importlib.reload(importlib.import_module("config.urls"))
return mod
def test_top_level_named_routes_available() -> None:
"""Top-level routes defined in `config.urls` are reversible."""
assert reverse("sitemap") == "/sitemap.xml"
assert reverse("robots") == "/robots.txt"
# ensure the included `twitch` namespace is present
assert reverse("twitch:dashboard") == "/"
def test_debug_tools_not_present_while_testing() -> None:
"""`silk` and Django Debug Toolbar URL patterns are not present while running tests."""
# Default test environment should *not* expose debug routes.
mod = _reload_urls_with(TESTING=True)
patterns = list(_pattern_strings(mod))
assert not any("silk" in p for p in patterns)
assert not any("__debug__" in p or "debug" in p for p in patterns)

View file

@ -73,7 +73,7 @@ services:
- POSTGRES_HOST=ttvdrops_postgres - POSTGRES_HOST=ttvdrops_postgres
- POSTGRES_PORT=5432 - POSTGRES_PORT=5432
volumes: volumes:
- /mnt/Docker/Data/ttvdrops/data:/root/.local/share/TTVDrops - /mnt/Docker/Data/ttvdrops/data:/home/ttvdrops/.local/share/TTVDrops
restart: unless-stopped restart: unless-stopped
networks: networks:
- web - web