Add phase 1: Docker setup, auth with invitations and roles
- Django 5.2 + PostgreSQL 17 via docker compose (app + db, volumes, healthcheck on /healthz, test profile running pytest against Postgres) - Custom e-mail user model with user/admin roles - Invitation-only registration with single-use, hashed, expiring links - Admin area: invitations, role changes, deactivation, password reset via one-time link or temporary password (forced change on login) - First admin created from ADMIN_EMAIL/ADMIN_PASSWORD on startup - Login rate limiting per e-mail and IP (DB-backed, worker-safe) - Proxy middleware honouring X-Forwarded-* only from TRUSTED_PROXIES, optional CLIENT_IP_HEADER (e.g. CF-Connecting-IP) - Security headers (CSP, HSTS, Permissions-Policy), secure __Host- cookies - Mobile-first German UI, CLAUDE.md, README, init-env script - 65 tests for auth, permissions, proxy handling and rate limiting Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
|
||||
from accounts.models import User
|
||||
|
||||
PASSWORD = "Sehr-sicheres-Passwort-42"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
return User.objects.create_admin(email="[email protected]", password=PASSWORD, display_name="Admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create_user(email="[email protected]", password=PASSWORD, display_name="Anna")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_user(db):
|
||||
return User.objects.create_user(email="[email protected]", password=PASSWORD, display_name="Ben")
|
||||
|
||||
|
||||
def logged_in_client(u):
|
||||
c = Client()
|
||||
c.force_login(u)
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(admin_user):
|
||||
return logged_in_client(admin_user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client(user):
|
||||
return logged_in_client(user)
|
||||
@@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import Invitation, User
|
||||
|
||||
from .conftest import PASSWORD
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["accounts:admin_users", "accounts:admin_invitations"])
|
||||
def test_admin_pages_forbidden_for_users(user_client, name):
|
||||
assert user_client.get(reverse(name)).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["accounts:admin_users", "accounts:admin_invitations"])
|
||||
def test_admin_pages_ok_for_admin(admin_client, name):
|
||||
assert admin_client.get(reverse(name)).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["make_admin", "deactivate", "reset_link", "temp_password"])
|
||||
def test_user_actions_forbidden_for_users(user_client, other_user, action):
|
||||
response = user_client.post(reverse("accounts:admin_user_action", args=[other_user.pk, action]))
|
||||
assert response.status_code == 403
|
||||
other_user.refresh_from_db()
|
||||
assert other_user.role == User.Role.USER and other_user.is_active and other_user.check_password(PASSWORD)
|
||||
|
||||
|
||||
def test_user_cannot_create_invitation(user_client):
|
||||
user_client.post(reverse("accounts:admin_invitations"), {"role": "admin", "valid_days": 7})
|
||||
assert not Invitation.objects.exists()
|
||||
|
||||
|
||||
def test_admin_creates_invitation(admin_client):
|
||||
response = admin_client.post(reverse("accounts:admin_invitations"), {"role": "user", "valid_days": 3, "note": "Oma"})
|
||||
assert response.status_code == 200
|
||||
assert "/einladung/" in response.context["value"]
|
||||
assert Invitation.objects.get().note == "Oma"
|
||||
|
||||
|
||||
def test_admin_can_promote_and_deactivate(admin_client, user):
|
||||
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "make_admin"]))
|
||||
user.refresh_from_db()
|
||||
assert user.role == User.Role.ADMIN
|
||||
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
|
||||
user.refresh_from_db()
|
||||
assert not user.is_active
|
||||
|
||||
|
||||
def test_deactivated_user_session_ends(admin_client, user):
|
||||
session = Client()
|
||||
session.force_login(user)
|
||||
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
|
||||
assert session.get(reverse("core:home")).status_code == 302
|
||||
|
||||
|
||||
def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user):
|
||||
for action in ["make_user", "deactivate"]:
|
||||
admin_client.post(reverse("accounts:admin_user_action", args=[admin_user.pk, action]))
|
||||
admin_user.refresh_from_db()
|
||||
assert admin_user.is_admin
|
||||
|
||||
|
||||
def test_unknown_action_rejected(admin_client, user):
|
||||
assert admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "delete"])).status_code == 403
|
||||
|
||||
|
||||
def test_admin_actions_require_post(admin_client, user):
|
||||
assert admin_client.get(reverse("accounts:admin_user_action", args=[user.pk, "make_admin"])).status_code == 405
|
||||
@@ -0,0 +1,269 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import Invitation, PasswordResetToken, User
|
||||
|
||||
from .conftest import PASSWORD
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
NEW_PASSWORD = "Ganz-neues-Passwort-77"
|
||||
|
||||
|
||||
def token_from(link):
|
||||
return link.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def login(client, email, password):
|
||||
return client.post(reverse("accounts:login"), {"email": email, "password": password})
|
||||
|
||||
|
||||
# --- Login ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_with_valid_credentials(user):
|
||||
client = Client()
|
||||
response = login(client, "[email protected]", PASSWORD) # e-mail is case-insensitive
|
||||
assert response.status_code == 302
|
||||
assert client.get(reverse("core:home")).status_code == 200
|
||||
|
||||
|
||||
def test_login_wrong_password_gives_generic_error(user):
|
||||
response = login(Client(), user.email, "falsch")
|
||||
assert response.status_code == 400
|
||||
assert "E-Mail oder Passwort ist falsch." in response.content.decode()
|
||||
|
||||
|
||||
def test_unknown_email_gives_same_error(db):
|
||||
response = login(Client(), "[email protected]", "egal")
|
||||
assert "E-Mail oder Passwort ist falsch." in response.content.decode()
|
||||
|
||||
|
||||
def test_inactive_user_cannot_login(user):
|
||||
user.is_active = False
|
||||
user.save()
|
||||
assert login(Client(), user.email, PASSWORD).status_code == 400
|
||||
|
||||
|
||||
def test_password_is_hashed_with_argon2(user):
|
||||
assert user.password.startswith("argon2")
|
||||
|
||||
|
||||
def test_login_rate_limit_per_email(user):
|
||||
client = Client()
|
||||
for _ in range(settings.LOGIN_MAX_FAILURES_PER_EMAIL):
|
||||
login(client, user.email, "falsch")
|
||||
# Even the correct password is refused while locked.
|
||||
response = login(client, user.email, PASSWORD)
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_login_rate_limit_per_ip(db, settings):
|
||||
settings.LOGIN_MAX_FAILURES_PER_IP = 3
|
||||
client = Client(REMOTE_ADDR="203.0.113.9")
|
||||
for i in range(3):
|
||||
login(client, f"x{i}@example.test", "falsch")
|
||||
assert login(client, "[email protected]", "falsch").status_code == 429
|
||||
# Other clients are unaffected.
|
||||
assert login(Client(REMOTE_ADDR="203.0.113.10"), "[email protected]", "falsch").status_code == 400
|
||||
|
||||
|
||||
def test_successful_login_resets_email_counter(user):
|
||||
client = Client()
|
||||
for _ in range(settings.LOGIN_MAX_FAILURES_PER_EMAIL - 1):
|
||||
login(client, user.email, "falsch")
|
||||
assert login(client, user.email, PASSWORD).status_code == 302
|
||||
client.post(reverse("accounts:logout"))
|
||||
login(client, user.email, "falsch")
|
||||
assert login(client, user.email, PASSWORD).status_code == 302
|
||||
|
||||
|
||||
def test_login_redirect_rejects_external_next(user):
|
||||
response = Client().post(
|
||||
reverse("accounts:login") + "?next=https://evil.example/",
|
||||
{"email": user.email, "password": PASSWORD, "next": "https://evil.example/"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response["Location"] == reverse("core:home")
|
||||
|
||||
|
||||
def test_logout_requires_post(user_client):
|
||||
assert user_client.get(reverse("accounts:logout")).status_code == 405
|
||||
assert user_client.post(reverse("accounts:logout")).status_code == 302
|
||||
assert user_client.get(reverse("core:home")).status_code == 302
|
||||
|
||||
|
||||
def test_login_requires_csrf_token(user):
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
response = login(client, user.email, PASSWORD)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# --- Login required everywhere ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
["/", "/konto/", "/konto/passwort/", "/verwaltung/nutzer/", "/verwaltung/einladungen/"],
|
||||
)
|
||||
def test_pages_require_login(db, url):
|
||||
response = Client().get(url)
|
||||
assert response.status_code == 302
|
||||
assert response["Location"].startswith(reverse("accounts:login"))
|
||||
|
||||
|
||||
def test_no_open_registration_route(db):
|
||||
for url in ["/registrieren/", "/register/", "/signup/", "/einladung/"]:
|
||||
assert Client().get(url).status_code in (302, 404)
|
||||
|
||||
|
||||
# --- Invitations ------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(client, token, **data):
|
||||
payload = {"email": "[email protected]", "display_name": "Neu", "password1": NEW_PASSWORD, "password2": NEW_PASSWORD}
|
||||
payload.update(data)
|
||||
return client.post(reverse("accounts:register", args=[token]), payload)
|
||||
|
||||
|
||||
def test_invitation_link_uses_app_base_url(admin_user):
|
||||
_, link = Invitation.issue(created_by=admin_user)
|
||||
assert link.startswith(settings.APP_BASE_URL + "/einladung/")
|
||||
|
||||
|
||||
def test_register_with_valid_invitation(admin_user):
|
||||
invitation, link = Invitation.issue(created_by=admin_user)
|
||||
client = Client()
|
||||
response = register(client, token_from(link))
|
||||
assert response.status_code == 302
|
||||
new_user = User.objects.get(email="[email protected]")
|
||||
assert new_user.role == User.Role.USER
|
||||
invitation.refresh_from_db()
|
||||
assert invitation.used_by == new_user and invitation.status == "used"
|
||||
assert client.get(reverse("core:home")).status_code == 200
|
||||
|
||||
|
||||
def test_invitation_is_single_use(admin_user):
|
||||
_, link = Invitation.issue(created_by=admin_user)
|
||||
register(Client(), token_from(link))
|
||||
response = register(Client(), token_from(link), email="[email protected]")
|
||||
assert response.status_code == 404
|
||||
assert not User.objects.filter(email="[email protected]").exists()
|
||||
|
||||
|
||||
def test_expired_invitation_rejected(admin_user):
|
||||
invitation, link = Invitation.issue(created_by=admin_user)
|
||||
invitation.expires_at = timezone.now() - timedelta(minutes=1)
|
||||
invitation.save()
|
||||
assert Client().get(reverse("accounts:register", args=[token_from(link)])).status_code == 404
|
||||
|
||||
|
||||
def test_revoked_invitation_rejected(admin_client, admin_user):
|
||||
invitation, link = Invitation.issue(created_by=admin_user)
|
||||
admin_client.post(reverse("accounts:admin_invitation_revoke", args=[invitation.pk]))
|
||||
assert Client().get(reverse("accounts:register", args=[token_from(link)])).status_code == 404
|
||||
|
||||
|
||||
def test_random_token_rejected(db):
|
||||
assert Client().get(reverse("accounts:register", args=["nicht-gueltig"])).status_code == 404
|
||||
|
||||
|
||||
def test_invitation_token_not_stored_in_plain_text(admin_user):
|
||||
invitation, link = Invitation.issue(created_by=admin_user)
|
||||
assert token_from(link) not in invitation.token_hash
|
||||
|
||||
|
||||
def test_invitation_bound_to_email(admin_user):
|
||||
_, link = Invitation.issue(created_by=admin_user, email="[email protected]")
|
||||
register(Client(), token_from(link), email="[email protected]")
|
||||
assert User.objects.filter(email="[email protected]").exists()
|
||||
assert not User.objects.filter(email="[email protected]").exists()
|
||||
|
||||
|
||||
def test_invitation_assigns_role(admin_user):
|
||||
_, link = Invitation.issue(created_by=admin_user, role=User.Role.ADMIN)
|
||||
register(Client(), token_from(link))
|
||||
assert User.objects.get(email="[email protected]").role == User.Role.ADMIN
|
||||
|
||||
|
||||
def test_register_rejects_existing_email(admin_user, user):
|
||||
_, link = Invitation.issue(created_by=admin_user)
|
||||
response = register(Client(), token_from(link), email=user.email.upper())
|
||||
assert response.status_code == 200
|
||||
assert User.objects.filter(email=user.email).count() == 1
|
||||
|
||||
|
||||
def test_register_rejects_weak_password(admin_user):
|
||||
_, link = Invitation.issue(created_by=admin_user)
|
||||
response = register(Client(), token_from(link), password1="12345678", password2="12345678")
|
||||
assert response.status_code == 200
|
||||
assert not User.objects.filter(email="[email protected]").exists()
|
||||
|
||||
|
||||
# --- Password reset by admin ------------------------------------------------------
|
||||
|
||||
|
||||
def test_reset_link_flow(admin_user, user):
|
||||
_, link = PasswordResetToken.issue(user, created_by=admin_user)
|
||||
client = Client()
|
||||
response = client.post(
|
||||
reverse("accounts:password_reset", args=[token_from(link)]),
|
||||
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
user.refresh_from_db()
|
||||
assert user.check_password(NEW_PASSWORD)
|
||||
# Single use
|
||||
assert Client().get(reverse("accounts:password_reset", args=[token_from(link)])).status_code == 404
|
||||
|
||||
|
||||
def test_new_reset_link_invalidates_old(admin_user, user):
|
||||
_, old = PasswordResetToken.issue(user, created_by=admin_user)
|
||||
PasswordResetToken.issue(user, created_by=admin_user)
|
||||
assert Client().get(reverse("accounts:password_reset", args=[token_from(old)])).status_code == 404
|
||||
|
||||
|
||||
def test_reset_link_expires(admin_user, user):
|
||||
token, link = PasswordResetToken.issue(user, created_by=admin_user)
|
||||
token.expires_at = timezone.now() - timedelta(seconds=1)
|
||||
token.save()
|
||||
assert Client().get(reverse("accounts:password_reset", args=[token_from(link)])).status_code == 404
|
||||
|
||||
|
||||
def test_reset_link_ends_existing_sessions(admin_user, user):
|
||||
old_session = Client()
|
||||
old_session.force_login(user)
|
||||
_, link = PasswordResetToken.issue(user, created_by=admin_user)
|
||||
Client().post(
|
||||
reverse("accounts:password_reset", args=[token_from(link)]),
|
||||
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
|
||||
)
|
||||
assert old_session.get(reverse("core:home")).status_code == 302
|
||||
|
||||
|
||||
def test_temp_password_forces_change(admin_client, user):
|
||||
response = admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "temp_password"]))
|
||||
temp = response.context["value"]
|
||||
client = Client()
|
||||
assert login(client, user.email, temp).status_code == 302
|
||||
# Every page redirects to the password change until a new password is set.
|
||||
assert client.get(reverse("core:home"))["Location"] == reverse("accounts:password_change")
|
||||
client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD})
|
||||
assert client.get(reverse("core:home")).status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert not user.must_change_password and user.check_password(NEW_PASSWORD)
|
||||
|
||||
|
||||
def test_own_password_change_requires_old_password(user_client, user):
|
||||
response = user_client.post(
|
||||
reverse("accounts:password_change"),
|
||||
{"old_password": "falsch", "password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert user.check_password(PASSWORD)
|
||||
@@ -0,0 +1,151 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.http import HttpResponse
|
||||
from django.test import Client, RequestFactory
|
||||
|
||||
from accounts.models import User
|
||||
from core import ratelimit
|
||||
from core.middleware import ProxyMiddleware
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# --- Proxy handling --------------------------------------------------------------
|
||||
|
||||
|
||||
def run_proxy(remote_addr, **headers):
|
||||
request = RequestFactory().get("/", REMOTE_ADDR=remote_addr, **headers)
|
||||
ProxyMiddleware(lambda r: HttpResponse())(request)
|
||||
return request
|
||||
|
||||
|
||||
def test_forwarded_headers_from_trusted_proxy_are_used():
|
||||
request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="198.51.100.7", HTTP_X_FORWARDED_PROTO="https")
|
||||
assert request.META["REMOTE_ADDR"] == "198.51.100.7"
|
||||
assert request.is_secure()
|
||||
|
||||
|
||||
def test_forwarded_headers_from_untrusted_peer_are_dropped():
|
||||
request = run_proxy("203.0.113.50", HTTP_X_FORWARDED_FOR="10.0.0.1", HTTP_X_FORWARDED_PROTO="https")
|
||||
assert request.META["REMOTE_ADDR"] == "203.0.113.50"
|
||||
assert "HTTP_X_FORWARDED_FOR" not in request.META
|
||||
assert not request.is_secure()
|
||||
|
||||
|
||||
def test_spoofed_left_entries_in_forwarded_for_are_ignored():
|
||||
# Client sent "X-Forwarded-For: 1.2.3.4"; the proxy appended the real client address.
|
||||
request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="1.2.3.4, 198.51.100.7")
|
||||
assert request.META["REMOTE_ADDR"] == "198.51.100.7"
|
||||
|
||||
|
||||
def test_chained_trusted_proxies_are_skipped():
|
||||
request = run_proxy("172.18.0.5", HTTP_X_FORWARDED_FOR="198.51.100.7, 192.168.1.2")
|
||||
assert request.META["REMOTE_ADDR"] == "198.51.100.7"
|
||||
|
||||
|
||||
def test_client_ip_header_only_from_trusted_proxy(settings):
|
||||
settings.CLIENT_IP_HEADER = "CF-Connecting-IP"
|
||||
trusted = run_proxy("172.18.0.5", HTTP_CF_CONNECTING_IP="198.51.100.8", HTTP_X_FORWARDED_FOR="162.158.1.1")
|
||||
assert trusted.META["REMOTE_ADDR"] == "198.51.100.8"
|
||||
untrusted = run_proxy("203.0.113.50", HTTP_CF_CONNECTING_IP="198.51.100.8")
|
||||
assert untrusted.META["REMOTE_ADDR"] == "203.0.113.50"
|
||||
|
||||
|
||||
# --- Security headers & health -----------------------------------------------------
|
||||
|
||||
|
||||
def test_security_headers(db):
|
||||
response = Client().get("/anmelden/")
|
||||
assert "default-src 'self'" in response["Content-Security-Policy"]
|
||||
assert response["X-Frame-Options"] == "DENY"
|
||||
assert response["X-Content-Type-Options"] == "nosniff"
|
||||
assert response["Referrer-Policy"] == "same-origin"
|
||||
assert "Permissions-Policy" in response
|
||||
|
||||
|
||||
def test_hsts_on_secure_requests(db):
|
||||
response = Client().get("/anmelden/", secure=True)
|
||||
assert "max-age=" in response["Strict-Transport-Security"]
|
||||
|
||||
|
||||
def test_session_cookie_flags(user):
|
||||
from django.conf import settings
|
||||
|
||||
from .conftest import PASSWORD
|
||||
|
||||
response = Client().post("/anmelden/", {"email": user.email, "password": PASSWORD})
|
||||
cookie = response.cookies[settings.SESSION_COOKIE_NAME]
|
||||
assert cookie["httponly"]
|
||||
assert cookie["samesite"] == "Lax"
|
||||
assert bool(cookie["secure"]) == settings.COOKIE_SECURE
|
||||
|
||||
|
||||
def test_authenticated_pages_not_cacheable(user_client):
|
||||
assert "no-store" in user_client.get("/")["Cache-Control"]
|
||||
|
||||
|
||||
def test_healthz(db):
|
||||
response = Client().get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
# --- Rate limiter ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ratelimit_counts_and_resets():
|
||||
window = timedelta(minutes=1)
|
||||
assert ratelimit.hit("k", 2, window)
|
||||
assert ratelimit.hit("k", 2, window)
|
||||
assert ratelimit.is_limited("k", 2, window)
|
||||
assert not ratelimit.hit("k", 2, window)
|
||||
ratelimit.reset("k")
|
||||
assert not ratelimit.is_limited("k", 2, window)
|
||||
|
||||
|
||||
def test_ratelimit_zero_means_unlimited():
|
||||
for _ in range(50):
|
||||
assert ratelimit.hit("u", 0, timedelta(minutes=1))
|
||||
assert not ratelimit.is_limited("u", 0, timedelta(minutes=1))
|
||||
|
||||
|
||||
def test_ratelimit_window_expires():
|
||||
window = timedelta(seconds=0)
|
||||
ratelimit.hit("w", 1, window)
|
||||
assert not ratelimit.is_limited("w", 1, window)
|
||||
assert ratelimit.hit("w", 1, window)
|
||||
|
||||
|
||||
# --- First admin from environment --------------------------------------------------
|
||||
|
||||
|
||||
def test_ensure_admin_creates_admin_once(monkeypatch):
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "[email protected]")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "Start-Passwort-2026")
|
||||
call_command("ensure_admin")
|
||||
admin = User.objects.get(email="[email protected]")
|
||||
assert admin.is_admin and admin.check_password("Start-Passwort-2026")
|
||||
|
||||
# A second run with a different password must not touch the existing account.
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "Anderes-Passwort-2026")
|
||||
call_command("ensure_admin")
|
||||
admin.refresh_from_db()
|
||||
assert admin.check_password("Start-Passwort-2026")
|
||||
assert User.objects.count() == 1
|
||||
|
||||
|
||||
def test_ensure_admin_rejects_weak_password(monkeypatch):
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "[email protected]")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", "123")
|
||||
with pytest.raises(CommandError):
|
||||
call_command("ensure_admin")
|
||||
assert not User.objects.exists()
|
||||
|
||||
|
||||
def test_ensure_admin_without_env_does_nothing(monkeypatch):
|
||||
monkeypatch.delenv("ADMIN_EMAIL", raising=False)
|
||||
call_command("ensure_admin")
|
||||
assert not User.objects.exists()
|
||||
Reference in New Issue
Block a user