feat: magic-link auth with access requests and RBAC
This commit is contained in:
parent
c2125f063b
commit
c8280f7078
13 changed files with 1058 additions and 2 deletions
228
backend/tests/test_auth.py
Normal file
228
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue