@@ -183,7 +183,8 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as exc: # pragma: no cover - env-dependent
|
||||
_run_html_accessibility_probe(client, user, profile, job)
|
||||
# De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
|
||||
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
|
||||
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
|
||||
|
||||
context = browser.new_context(
|
||||
|
||||
@@ -11,7 +11,6 @@ from django.urls import reverse
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_login_page_renders_stitch_design(client):
|
||||
response = client.get(reverse("login"))
|
||||
assert response.status_code == 200
|
||||
@@ -42,7 +41,6 @@ def test_login_page_keeps_official_brand_separate_from_owner_setting(client):
|
||||
assert "Eigen merk" not in body
|
||||
assert 'content="VacatureRadar"' in body
|
||||
|
||||
|
||||
def test_login_with_email(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
@@ -52,7 +50,6 @@ def test_login_with_email(client, user):
|
||||
assert response.url == reverse("dashboard:today")
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
|
||||
def test_login_with_username_still_works(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
@@ -61,7 +58,6 @@ def test_login_with_username_still_works(client, user):
|
||||
assert response.status_code == 302
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
|
||||
def test_login_email_case_insensitive(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
@@ -70,7 +66,6 @@ def test_login_email_case_insensitive(client, user):
|
||||
assert response.status_code == 302
|
||||
assert client.session.get("_auth_user_id") == str(user.pk)
|
||||
|
||||
|
||||
def test_login_wrong_password_shows_error(client, user):
|
||||
response = client.post(
|
||||
reverse("login"),
|
||||
@@ -79,7 +74,6 @@ def test_login_wrong_password_shows_error(client, user):
|
||||
assert response.status_code == 200
|
||||
assert "klopt niet" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_password_reset_sends_email(client, user):
|
||||
response = client.post(reverse("password_reset"), {"email": user.email})
|
||||
assert response.status_code == 302
|
||||
@@ -90,13 +84,11 @@ def test_password_reset_sends_email(client, user):
|
||||
assert "wachtwoord" in message.subject.lower()
|
||||
assert "/wachtwoord/herstellen/" in message.body
|
||||
|
||||
|
||||
def test_password_reset_unknown_email_is_silent(client):
|
||||
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
|
||||
assert response.status_code == 302
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
|
||||
def test_demo_button_visible_by_default(client):
|
||||
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
|
||||
|
||||
@@ -113,7 +105,6 @@ def test_demo_login_disabled_redirects(client):
|
||||
def test_demo_button_hidden_when_disabled(client):
|
||||
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
|
||||
|
||||
|
||||
def test_demo_login_logs_in_and_seeds_environment(client):
|
||||
from apps.jobs.models import Application, JobPosting, ScoreRun
|
||||
from apps.profiles.models import SearchProfile
|
||||
@@ -136,7 +127,6 @@ def test_demo_login_logs_in_and_seeds_environment(client):
|
||||
dashboard = client.get(reverse("dashboard:today"))
|
||||
assert dashboard.status_code == 200
|
||||
|
||||
|
||||
def test_demo_login_is_idempotent(client):
|
||||
from apps.jobs.models import JobPosting
|
||||
|
||||
@@ -145,6 +135,39 @@ def test_demo_login_is_idempotent(client):
|
||||
client.post(reverse("demo-login"))
|
||||
assert JobPosting.objects.count() == first
|
||||
|
||||
def test_demo_account_is_read_only_and_session_is_bounded(client):
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
client.post(reverse("demo-login"))
|
||||
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
|
||||
active = SearchProfile.objects.get(user=demo_user, is_active=True)
|
||||
alternative = SearchProfile.objects.create(user=demo_user, name="Alternatief", is_active=False)
|
||||
|
||||
response = client.post(
|
||||
reverse("profiles:activate", args=[alternative.pk]),
|
||||
HTTP_REFERER=reverse("profiles:list"),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("profiles:list")
|
||||
active.refresh_from_db()
|
||||
alternative.refresh_from_db()
|
||||
assert active.is_active is True
|
||||
assert alternative.is_active is False
|
||||
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
|
||||
|
||||
def test_demo_account_can_still_log_out(client):
|
||||
client.post(reverse("demo-login"))
|
||||
response = client.post(reverse("logout"))
|
||||
assert response.status_code == 302
|
||||
assert client.session.get("_auth_user_id") is None
|
||||
|
||||
def test_authenticated_demo_page_disables_mutating_forms(client):
|
||||
client.post(reverse("demo-login"))
|
||||
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
|
||||
assert 'data-demo-read-only="true"' in body
|
||||
assert "Publieke demo · alleen-lezen" in body
|
||||
assert "data-demo-allow" in body
|
||||
|
||||
def test_entra_login_disabled_redirects(client):
|
||||
response = client.post(reverse("entra-login"))
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid():
|
||||
|
||||
def test_css_imports_share_the_release_cache_version():
|
||||
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
|
||||
assert css.count("?v=0.3.11") == 7
|
||||
assert css.count("?v=0.3.12") == 7
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -89,3 +89,38 @@ def test_unraid_deploy_requires_dedicated_ssh_alias() -> None:
|
||||
assert "git ls-files --cached --others --exclude-standard -z" in deploy_script
|
||||
assert '[[ -e "$tracked_path" ]]' in deploy_script
|
||||
assert "/health/ready/" in deploy_script
|
||||
|
||||
|
||||
def test_public_https_origin_uses_external_port_not_internal_app_port() -> None:
|
||||
deploy_script = (ROOT / "scripts" / "deploy_docker.sh").read_text(encoding="utf-8")
|
||||
assert 'app_public_port="${APP_PUBLIC_PORT:-}"' in deploy_script
|
||||
assert 'app_public_port="443"' in deploy_script
|
||||
assert 'write_env "PUBLIC_BASE_URL" "$app_base_url" 1' in deploy_script
|
||||
assert 'https://$app_host:$app_port' not in deploy_script
|
||||
|
||||
|
||||
def test_health_endpoints_are_exempt_from_internal_https_redirect() -> None:
|
||||
assert r"^health/(?:live|ready)/$" in settings.SECURE_REDIRECT_EXEMPT
|
||||
|
||||
|
||||
def test_unraid_uses_dedicated_shared_cache_database() -> None:
|
||||
compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8"))
|
||||
environment = compose["services"]["app"]["environment"]
|
||||
assert environment["CACHE_URL"] == "redis://127.0.0.1:6379/1"
|
||||
assert environment["HEALTHCHECK_REQUIRE_CACHE"] == "1"
|
||||
|
||||
|
||||
def test_unraid_database_url_is_built_inside_the_container() -> None:
|
||||
compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8"))
|
||||
environment = compose["services"]["app"]["environment"]
|
||||
assert "DATABASE_URL" not in environment
|
||||
|
||||
|
||||
def test_deploy_uses_selected_environment_file_for_compose() -> None:
|
||||
compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8"))
|
||||
deploy_script = (ROOT / "scripts" / "deploy_docker.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert compose["x-app"]["env_file"] == ["${VACATURERADAR_ENV_FILE:-.env}"]
|
||||
assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script
|
||||
assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script
|
||||
assert 'waarde uit ${env_file}' in deploy_script
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.core.health import readiness
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
|
||||
def test_readiness_checks_database_and_shared_cache():
|
||||
result = readiness()
|
||||
assert result.ok is True
|
||||
assert result.checks == {"database": "ok", "cache": "ok"}
|
||||
|
||||
|
||||
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
|
||||
def test_readiness_reports_cache_failure(monkeypatch):
|
||||
def fail_cache(*args, **kwargs):
|
||||
raise ConnectionError
|
||||
|
||||
monkeypatch.setattr("apps.core.health.cache.set", fail_cache)
|
||||
result = readiness()
|
||||
assert result.ok is False
|
||||
assert result.checks["database"] == "ok"
|
||||
assert result.checks["cache"] == "error:ConnectionError"
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
from apps.core.network import client_ip
|
||||
|
||||
|
||||
def _request(remote: str, forwarded: str = ""):
|
||||
request = RequestFactory().get("/")
|
||||
request.META["REMOTE_ADDR"] = remote
|
||||
if forwarded:
|
||||
request.META["HTTP_X_FORWARDED_FOR"] = forwarded
|
||||
return request
|
||||
|
||||
|
||||
@override_settings(TRUSTED_PROXY_CIDRS=[])
|
||||
def test_client_ip_ignores_untrusted_forwarded_header():
|
||||
assert client_ip(_request("203.0.113.20", "198.51.100.7")) == "203.0.113.20"
|
||||
|
||||
|
||||
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
|
||||
def test_client_ip_uses_forwarded_chain_only_from_trusted_proxy():
|
||||
request = _request("172.18.0.4", "198.51.100.7, 172.18.0.3")
|
||||
assert client_ip(request) == "198.51.100.7"
|
||||
|
||||
|
||||
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
|
||||
def test_client_ip_skips_invalid_forwarded_values():
|
||||
request = _request("172.18.0.4", "invalid, 198.51.100.9")
|
||||
assert client_ip(request) == "198.51.100.9"
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from config.settings import normalize_public_base_url
|
||||
from scripts.configure_public_url import ConfigurationError, configure_env, normalize_public_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "origin", "host"),
|
||||
[
|
||||
(
|
||||
"https://vacatureradar.itworx.tech/",
|
||||
"https://vacatureradar.itworx.tech",
|
||||
"vacatureradar.itworx.tech",
|
||||
),
|
||||
("https://jobs.example.be:8443", "https://jobs.example.be:8443", "jobs.example.be"),
|
||||
("http://127.0.0.1:1226", "http://127.0.0.1:1226", "127.0.0.1"),
|
||||
],
|
||||
)
|
||||
def test_public_url_normalization(raw, origin, host):
|
||||
assert normalize_public_base_url(raw) == (origin, host)
|
||||
assert normalize_public_url(raw) == (origin, host)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"vacatureradar.itworx.tech",
|
||||
"ftp://vacatureradar.itworx.tech",
|
||||
"https://user:secret@example.org",
|
||||
"https://example.org/app",
|
||||
"https://example.org?debug=1",
|
||||
],
|
||||
)
|
||||
def test_public_url_rejects_unsafe_or_unsupported_values(raw):
|
||||
with pytest.raises((ImproperlyConfigured, ConfigurationError)):
|
||||
normalize_public_base_url(raw)
|
||||
|
||||
def test_configure_public_url_updates_only_public_security_settings(tmp_path: Path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"DJANGO_SECRET_KEY=keep-this-secret\n"
|
||||
"POSTGRES_PASSWORD=keep-this-password\n"
|
||||
"DJANGO_ALLOWED_HOSTS=192.168.10.150,localhost\n"
|
||||
"DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
changed = configure_env(
|
||||
env_file,
|
||||
"https://vacatureradar.itworx.tech/",
|
||||
hsts_seconds=300,
|
||||
cache_url="redis://127.0.0.1:6379/1",
|
||||
trusted_proxy_cidrs=["172.18.0.0/16"],
|
||||
)
|
||||
content = env_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "PUBLIC_BASE_URL=https://vacatureradar.itworx.tech" in content
|
||||
assert "DJANGO_DEBUG=0" in content
|
||||
assert "vacatureradar.itworx.tech" in content
|
||||
assert (
|
||||
"DJANGO_CSRF_TRUSTED_ORIGINS="
|
||||
"http://192.168.10.150:1226,https://vacatureradar.itworx.tech"
|
||||
) in content
|
||||
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
|
||||
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
|
||||
assert "DJANGO_SECRET_KEY=keep-this-secret" in content
|
||||
assert "POSTGRES_PASSWORD=keep-this-password" in content
|
||||
assert env_file.stat().st_mode & 0o777 == 0o600
|
||||
assert "DJANGO_SECRET_KEY" not in changed
|
||||
|
||||
def test_configure_public_url_rejects_plain_http(tmp_path: Path):
|
||||
with pytest.raises(ConfigurationError, match="HTTPS"):
|
||||
configure_env(tmp_path / ".env", "http://vacatureradar.example.be")
|
||||
|
||||
def test_public_base_url_populates_django_host_and_csrf_settings():
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"DJANGO_SECRET_KEY": "aB3!" * 16,
|
||||
"DJANGO_DEBUG": "0",
|
||||
"PUBLIC_BASE_URL": "https://vacatureradar.itworx.tech",
|
||||
"DEMO_MODE_ENABLED": "0",
|
||||
"SECURE_HSTS_SECONDS": "300",
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import config.settings as settings; "
|
||||
"assert 'vacatureradar.itworx.tech' in settings.ALLOWED_HOSTS; "
|
||||
"assert 'https://vacatureradar.itworx.tech' "
|
||||
"in settings.CSRF_TRUSTED_ORIGINS; "
|
||||
"assert settings.DEBUG is False; "
|
||||
"assert settings.SESSION_COOKIE_SECURE is True; "
|
||||
"assert settings.CSRF_COOKIE_SECURE is True; "
|
||||
"assert settings.SECURE_SSL_REDIRECT is True"
|
||||
),
|
||||
],
|
||||
cwd=Path(__file__).resolve().parents[2],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
) # noqa: S603 - fixed interpreter and static assertion program
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_configure_public_url_is_idempotent(tmp_path: Path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("DJANGO_ALLOWED_HOSTS=localhost\n", encoding="utf-8")
|
||||
|
||||
for _ in range(2):
|
||||
configure_env(env_file, "https://vacatureradar.example.be")
|
||||
|
||||
content = env_file.read_text(encoding="utf-8")
|
||||
assert content.count("PUBLIC_BASE_URL=") == 1
|
||||
assert content.count("vacatureradar.example.be") == 3
|
||||
@@ -45,6 +45,7 @@ def test_validate_production_security_skips_when_debug_mode_is_enabled():
|
||||
("change-me", ["example.org"], ["https://example.org"], True, True, True),
|
||||
("too-short", ["example.org"], ["https://example.org"], True, True, True),
|
||||
("a" * 64, [], ["https://example.org"], True, True, True),
|
||||
("a" * 64, ["*"], ["https://example.org"], True, True, True),
|
||||
("a" * 64, ["example.org"], [], True, True, True),
|
||||
("a" * 64, ["example.org"], ["http://example.org"], True, True, True),
|
||||
("a" * 64, ["example.org"], ["https://example.org"], False, True, True),
|
||||
@@ -70,3 +71,34 @@ def test_validate_production_security_requires_secure_settings_for_prod(
|
||||
csrf_cookie_secure=csrf_cookie_secure,
|
||||
secure_ssl_redirect=secure_ssl_redirect,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_production_security_rejects_writable_public_demo():
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
_validate_production_security(
|
||||
debug=False,
|
||||
secret_key="a" * 64,
|
||||
allowed_hosts=["vacatureradar.itworx.tech"],
|
||||
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
|
||||
session_cookie_secure=True,
|
||||
csrf_cookie_secure=True,
|
||||
secure_ssl_redirect=True,
|
||||
public_base_url="https://vacatureradar.itworx.tech",
|
||||
demo_mode_enabled=True,
|
||||
demo_read_only=False,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_production_security_accepts_read_only_https_demo():
|
||||
_validate_production_security(
|
||||
debug=False,
|
||||
secret_key="a" * 64,
|
||||
allowed_hosts=["vacatureradar.itworx.tech"],
|
||||
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
|
||||
session_cookie_secure=True,
|
||||
csrf_cookie_secure=True,
|
||||
secure_ssl_redirect=True,
|
||||
public_base_url="https://vacatureradar.itworx.tech",
|
||||
demo_mode_enabled=True,
|
||||
demo_read_only=True,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user