from urllib.parse import parse_qs, urlsplit import pytest from django.test import Client from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung pytestmark = pytest.mark.django_db def test_anonymous_cannot_read_tenders(client): Ausschreibung.objects.create(titel='Confidential tender', ausschreiber='Pilot') response = client.get('/ausschreibungen/') assert response.status_code == 302 assert urlsplit(response.url).path == '/accounts/login/' assert b'Confidential tender' not in response.content def test_anonymous_cannot_create_tender(client): response = client.post('/ausschreibungen/neu/', { 'titel': 'Unauthorised tender', 'ausschreiber': 'Pilot', }) assert response.status_code == 302 assert not Ausschreibung.objects.exists() def test_expired_htmx_session_requires_full_login(client): response = client.get('/suche/?q=private', HTTP_HX_REQUEST='true') assert response.status_code == 401 assert urlsplit(response['HX-Redirect']).path == '/accounts/login/' # A fragment URL must never become the full page after login. assert parse_qs(urlsplit(response['HX-Redirect']).query)['next'] == ['/'] def test_health_is_public_and_contains_no_tenant_data(client): assert client.get('/health/').json() == {'status': 'ok'} def test_invited_login_redirect_and_logout(mitarbeiter): client = Client(enforce_csrf_checks=True) response = client.get('/accounts/login/?next=/ausschreibungen/') assert response.status_code == 200 assert b'Benutzername' in response.content assert b'search-results' not in response.content credentials = {'username': mitarbeiter.username, 'password': 'testpass', 'next': '/ausschreibungen/'} assert client.post('/accounts/login/', credentials).status_code == 403 credentials['csrfmiddlewaretoken'] = client.cookies['csrftoken'].value response = client.post('/accounts/login/', credentials) assert response.status_code == 302 assert response.url == '/ausschreibungen/' assert client.get(response.url).status_code == 200 assert client.get('/accounts/logout/').status_code == 405 assert client.post('/accounts/logout/').status_code == 403 response = client.post('/accounts/logout/', { 'csrfmiddlewaretoken': client.cookies['csrftoken'].value, }) assert response.url == '/accounts/login/' assert client.get('/ausschreibungen/').status_code == 302 def test_login_rejects_external_next(client, mitarbeiter): response = client.post('/accounts/login/', { 'username': mitarbeiter.username, 'password': 'testpass', 'next': 'https://untrusted.example/private', }) assert response.status_code == 302 assert response.url == '/' def test_deactivated_member_loses_existing_session_and_cannot_login(client, mitarbeiter): client.force_login(mitarbeiter) mitarbeiter.is_active = False mitarbeiter.save(update_fields=['is_active']) assert client.get('/ausschreibungen/').status_code == 302 response = client.post('/accounts/login/', { 'username': mitarbeiter.username, 'password': 'testpass', }) assert response.status_code == 200 assert response.context['form'].errors assert client.get('/ausschreibungen/').status_code == 302 def test_member_password_change_keeps_session_and_replaces_password(client, mitarbeiter): client.force_login(mitarbeiter) assert client.get('/accounts/password-change/').status_code == 200 response = client.post('/accounts/password-change/', { 'old_password': 'testpass', 'new_password1': 'A-unique-pilot-password-914!', 'new_password2': 'A-unique-pilot-password-914!', }) assert response.status_code == 302 assert client.get(response.url).status_code == 200 assert client.get('/ausschreibungen/').status_code == 200 mitarbeiter.refresh_from_db() assert not mitarbeiter.check_password('testpass') assert mitarbeiter.check_password('A-unique-pilot-password-914!') def test_company_member_does_not_gain_django_admin_access(client, mitarbeiter): client.force_login(mitarbeiter) response = client.get('/admin/') assert response.status_code == 302 assert urlsplit(response.url).path == '/admin/login/' @pytest.mark.parametrize('debug', [False, True]) def test_media_requires_login_and_downloads_as_private_attachment( client, mitarbeiter, tmp_path, settings, debug, ): settings.DEBUG = debug settings.MEDIA_ROOT = tmp_path / 'media' settings.MEDIA_ROOT.mkdir() (settings.MEDIA_ROOT / 'tender.pdf').write_bytes(b'%PDF-pilot') assert client.get('/media/tender.pdf').status_code == 302 client.force_login(mitarbeiter) response = client.get('/media/tender.pdf') assert response.status_code == 200 assert b''.join(response.streaming_content) == b'%PDF-pilot' assert response['Content-Disposition'] == 'attachment; filename="tender.pdf"' assert response['X-Content-Type-Options'] == 'nosniff' assert 'no-store' in response['Cache-Control'] def test_media_rejects_traversal_symlink_directory_and_missing_file( client, mitarbeiter, tmp_path, settings, ): settings.MEDIA_ROOT = tmp_path / 'media' settings.MEDIA_ROOT.mkdir() private = tmp_path / 'issues.db' private.write_bytes(b'private issue state') (settings.MEDIA_ROOT / 'escape.pdf').symlink_to(private) (settings.MEDIA_ROOT / 'folder').mkdir() client.force_login(mitarbeiter) for path in ('../issues.db', '%2e%2e/issues.db', 'escape.pdf', 'folder', 'absent.pdf'): response = client.get('/media/' + path) assert response.status_code == 404 assert b'private issue state' not in response.content