kirche-einfach/backend/tests/test_auth.py

326 lines
12 KiB
Python

"""Phase 1 tests: magic-link auth, access requests, RBAC, cookie flags."""
import logging
from datetime import timedelta
from uuid import uuid4
import pytest
from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE, resolve_tenant
from app.models import MagicLinkToken, User
from app.settings import settings
ADMIN = "admin@example.com"
MEMBER = "member@example.com"
@pytest.fixture(autouse=True)
def _no_smtp_many_admins(monkeypatch):
# Never touch the network: dev fallback logs instead of mailing.
# (The real repo .env configures SMTP; tests must not use it.)
monkeypatch.setattr(settings, "smtp_host", "")
monkeypatch.setattr(settings, "admin_emails", ADMIN)
def _get_or_make_user(session, email, role="member"):
user = service.get_user_by_email(session, email)
if user is None:
user = User(email=email, name="Test", role=role)
session.add(user)
session.commit()
session.refresh(user)
return user
async def _login(client, session, email, role="member"):
"""Consume a fresh magic token; persist the session cookie manually.
httpx honours the Secure cookie flag and would withhold it over the
http://test transport, so the session id is re-set as a plain cookie.
"""
user = _get_or_make_user(session, email, role)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200, response.text
sid = response.cookies[SESSION_COOKIE]
client.cookies.set(SESSION_COOKIE, sid)
return response.json()
async def test_request_approve_consume_me_logout(client, session):
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 201
admin = await _login(client, session, ADMIN, role="admin")
assert admin["role"] == "admin"
created = await client.post(
"/api/access/requests",
json={"name": "Maria", "email": MEMBER, "reason": "choir planning"},
)
assert created.status_code == 201, created.text
assert created.json()["status"] == "pending"
queue = await client.get("/api/access/requests")
assert queue.status_code == 200
assert any(r["email"] == MEMBER for r in queue.json())
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["request"]["status"] == "approved"
assert body["email_sent"] is False # no SMTP in tests
# New client (no admin cookie) logs in as the approved member.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as member:
user = service.get_user_by_email(session, MEMBER)
assert user is not None and user.role == "member"
token = service.issue_magic_token(session, user)
consumed = await member.post("/api/auth/magic/consume", json={"token": token})
assert consumed.status_code == 200
assert consumed.json()["email"] == MEMBER
member.cookies.set(SESSION_COOKIE, consumed.cookies[SESSION_COOKIE])
me = await member.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200
assert (await member.get("/api/auth/me")).status_code == 401
async def test_consume_unknown_token_400(client):
response = await client.post("/api/auth/magic/consume", json={"token": "nope"})
assert response.status_code == 400
async def test_token_expiry_boundary(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == service.hash_token(token))
).one()
record.expires_at = service.utcnow() - timedelta(seconds=1)
session.add(record)
session.commit()
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 400
assert "expired" in response.json()["detail"]
async def test_token_reuse_400(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
assert (await client.post("/api/auth/magic/consume", json={"token": token})).status_code == 200
again = await client.post("/api/auth/magic/consume", json={"token": token})
assert again.status_code == 400
assert "used" in again.json()["detail"]
async def test_admin_only_endpoints(client, session):
await _login(client, session, MEMBER, role="member")
created = await client.post(
"/api/access/requests",
json={"name": "X", "email": "x@example.com", "reason": ""},
)
assert created.status_code == 201
assert (await client.get("/api/access/requests")).status_code == 403
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
).status_code == 401
assert (await anon.get("/api/auth/me")).status_code == 401
async def test_approve_unknown_and_double_decide(client, session):
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin")
assert (await client.post(f"/api/access/requests/{uuid4()}/approve")).status_code == 404
created = await client.post(
"/api/access/requests",
json={"name": "Y", "email": "y@example.com", "reason": ""},
)
rid = created.json()["id"]
assert (await client.post(f"/api/access/requests/{rid}/reject")).status_code == 200
again = await client.post(f"/api/access/requests/{rid}/approve")
assert again.status_code == 409
async def test_bootstrap_once_and_forbidden(client):
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": "stranger@example.com"})
).status_code == 403
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 201
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 409
async def test_session_cookie_flags(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
raw = " ".join(response.headers.get_list("set-cookie")).lower()
assert "httponly" in raw
assert "secure" in raw
assert "samesite=lax" in raw
assert "path=/" in raw
async def test_no_token_or_session_in_logs(client, session, caplog):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
with caplog.at_level(logging.INFO):
caplog.clear()
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
assert token not in caplog.text
assert response.cookies[SESSION_COOKIE] not in caplog.text
assert service.hash_token(token) not in caplog.text
async def test_access_request_rate_limit(client):
email = f"impatient-{uuid4()}@example.com"
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR):
response = await client.post(
"/api/access/requests",
json={"name": "I", "email": email, "reason": ""},
)
assert response.status_code == 201, response.text
limited = await client.post(
"/api/access/requests", json={"name": "I", "email": email, "reason": ""}
)
assert limited.status_code == 429
def test_resolve_tenant():
assert resolve_tenant(None, None) == "default"
assert resolve_tenant(None, "acme") == "acme"
user = User(email="a@b.c", tenant_id="parish-7")
assert resolve_tenant(user, None) == "parish-7"
assert resolve_tenant(user, "other") == "parish-7"
def _broken_smtp(monkeypatch):
import smtplib
from app.auth import mail
class ExplodingSMTP:
def __init__(self, *args, **kwargs):
raise smtplib.SMTPException("relay down")
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
def test_magic_link_delivery_failure_returns_false(monkeypatch):
from app.auth import mail
monkeypatch.setattr(settings, "smtp_host", "dummy")
_broken_smtp(monkeypatch)
assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False
assert mail.send_access_notification("req-1", "A", "a@example.com") is False
async def test_approve_survives_delivery_failure(client, session, monkeypatch):
monkeypatch.setattr(settings, "smtp_host", "dummy")
_broken_smtp(monkeypatch)
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin")
created = await client.post(
"/api/access/requests",
json={"name": "M", "email": "m@example.com", "reason": ""},
)
assert created.status_code == 201, created.text
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["request"]["status"] == "approved"
assert body["email_sent"] is False
async def test_magic_request_for_known_user(client, session):
_get_or_make_user(session, MEMBER)
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert response.status_code == 200, response.text
body = response.json()
assert body["message"] == "login link sent"
assert body["email_sent"] is False # no SMTP in tests
assert "token" not in response.text
tokens = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.user_id == service.get_user_by_email(session, MEMBER).id
)
).all()
assert len(tokens) == 1
async def test_magic_request_unknown_email_403(client):
response = await client.post("/api/auth/magic/request", json={"email": "stranger@example.com"})
assert response.status_code == 403
async def test_magic_request_admin_email_creates_admin(client, session):
response = await client.post("/api/auth/magic/request", json={"email": ADMIN})
assert response.status_code == 200, response.text
user = service.get_user_by_email(session, ADMIN)
assert user is not None and user.role == "admin"
async def test_magic_request_rate_limit(client, session):
_get_or_make_user(session, MEMBER)
for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert response.status_code == 200, response.text
limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert limited.status_code == 429
def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
from app.auth import mail
monkeypatch.setattr(settings, "smtp_host", "dummy")
message = mail._branded_mail(
subject="Dein Login-Link",
to_email="a@example.com",
headline="Anmeldung bei Kirche Einfach",
intro="Klicke auf den Button.",
button_label="Jetzt anmelden",
button_url="http://localhost:3000/login?token=GEHEIM",
note="Gültig 15 Minuten.",
)
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
assert ">Jetzt anmelden</a>" in html
assert mail.BLUE in html # kirchenblau brand bar
assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]
assert "text/plain" in parts and "text/html" in parts
plain = next(p.get_content() for p in message.walk() if p.get_content_type() == "text/plain")
assert "http://localhost:3000/login?token=GEHEIM" in plain