auth: subscribe-to-digests checkbox on verify (default on)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
14fe47103f
commit
f247f66a3c
3 changed files with 94 additions and 0 deletions
|
|
@ -216,6 +216,7 @@ async def verify_page(request: Request, error: str | None = None, sent: str | No
|
||||||
async def verify_submit(
|
async def verify_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
code: str = Form(...),
|
code: str = Form(...),
|
||||||
|
subscribe_to_digests: str | None = Form(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
cookie = request.cookies.get(PENDING_COOKIE_NAME)
|
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.
|
# User row vanished between cookie issue and verify. Restart flow.
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
user.last_login_at = utcnow()
|
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()
|
await session.commit()
|
||||||
log.info("user.login", user_id=user.id, email=email)
|
log.info("user.login", user_id=user.id, email=email)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@
|
||||||
minlength="6" maxlength="6" required autofocus
|
minlength="6" maxlength="6" required autofocus
|
||||||
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
|
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:block; margin:14px 0 0; font-size:12.5px; color:var(--muted); line-height:1.55;">
|
||||||
|
<input type="checkbox" name="subscribe_to_digests" value="on" checked
|
||||||
|
style="vertical-align:middle; margin-right:6px;">
|
||||||
|
Email me the digest — daily for paid, Sunday for everyone.
|
||||||
|
One-click unsubscribe in every email.
|
||||||
|
</label>
|
||||||
<button type="submit">Verify</button>
|
<button type="submit">Verify</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
85
tests/test_verify_subscribe.py
Normal file
85
tests/test_verify_subscribe.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue