diff --git a/app/routers/auth.py b/app/routers/auth.py index 59733a9..6d3bfbc 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -216,6 +216,7 @@ async def verify_page(request: Request, error: str | None = None, sent: str | No async def verify_submit( request: Request, code: str = Form(...), + subscribe_to_digests: str | None = Form(default=None), session: AsyncSession = Depends(get_session), ): cookie = request.cookies.get(PENDING_COOKIE_NAME) @@ -240,6 +241,8 @@ async def verify_submit( # User row vanished between cookie issue and verify. Restart flow. return RedirectResponse(url="/login", status_code=303) user.last_login_at = utcnow() + # An unchecked HTML checkbox sends NO field; that means "opt out". + user.email_digest_opt_in = subscribe_to_digests is not None await session.commit() log.info("user.login", user_id=user.id, email=email) diff --git a/app/templates/verify.html b/app/templates/verify.html index ae62056..8596939 100644 --- a/app/templates/verify.html +++ b/app/templates/verify.html @@ -32,6 +32,12 @@ minlength="6" maxlength="6" required autofocus style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;"> + diff --git a/tests/test_verify_subscribe.py b/tests/test_verify_subscribe.py new file mode 100644 index 0000000..51477c0 --- /dev/null +++ b/tests/test_verify_subscribe.py @@ -0,0 +1,85 @@ +"""Verify-POST persists the subscribe_to_digests form field.""" +from __future__ import annotations + +import asyncio + + +def _build(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.db import Base + from app.models import User + from app.routers import auth as auth_router + from app.auth import _pending_serializer + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/v.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + async def _seed(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + s.add(User(id=10, email="newbie@x", tier="free", + email_digest_opt_in=True)) + await s.commit() + + asyncio.run(_seed()) + app = FastAPI() + app.include_router(auth_router.router) + pending = _pending_serializer().dumps({"email": "newbie@x", "uid": 10, "ref": None}) + return TestClient(app), pending + + +def test_verify_with_unchecked_subscribe_disables_opt_in(tmp_path, monkeypatch): + from app.services import otp_service + + async def _ok(*args, **kwargs): + return None + monkeypatch.setattr(otp_service, "verify", _ok) + + client, pending = _build(tmp_path) + r = client.post( + "/verify", + data={"code": "000000"}, # subscribe_to_digests omitted = unchecked + cookies={"cassandra_pending": pending}, + follow_redirects=False, + ) + assert r.status_code == 303, r.text + + async def _check(): + from app import db as db_mod + from app.models import User + async with db_mod._session_factory() as s: + u = await s.get(User, 10) + assert u.email_digest_opt_in is False + asyncio.run(_check()) + + +def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch): + from app.services import otp_service + + async def _ok(*args, **kwargs): + return None + monkeypatch.setattr(otp_service, "verify", _ok) + + client, pending = _build(tmp_path) + r = client.post( + "/verify", + data={"code": "000000", "subscribe_to_digests": "on"}, + cookies={"cassandra_pending": pending}, + follow_redirects=False, + ) + assert r.status_code == 303 + + async def _check(): + from app import db as db_mod + from app.models import User + async with db_mod._session_factory() as s: + u = await s.get(User, 10) + assert u.email_digest_opt_in is True + asyncio.run(_check())