"""`users.last_login_at` (E22 S1), DB-lane. The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It would not have: only password login called it, and the Google callback carried its own copy of the same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp (a refused password, the shared demo account) and the admin list that shows it. """ from datetime import datetime, timedelta, timezone from app.config import settings from app.models import DemoWhitelist from app.routes import auth as auth_routes _PW = "correct horse battery" def _recent(ts: datetime | None) -> bool: return ts is not None and datetime.now(timezone.utc) - ts < timedelta(minutes=1) def test_a_password_sign_in_stamps_last_login(client, db, make_user): user = make_user(email="pw@example.com", password=_PW) assert user.last_login_at is None r = client.post("/auth/password-login", json={"email": "pw@example.com", "password": _PW}) assert r.status_code == 200 db.refresh(user) assert _recent(user.last_login_at) def test_a_refused_password_does_not_stamp(client, db, make_user): user = make_user(email="wrong@example.com", password=_PW) r = client.post( "/auth/password-login", json={"email": "wrong@example.com", "password": "not it"} ) assert r.status_code == 401 db.refresh(user) assert user.last_login_at is None class _FakeGoogle: """authlib's client, reduced to the one call the callback makes after Google redirects back.""" def __init__(self, userinfo: dict): self._userinfo = userinfo async def authorize_access_token(self, _request): return {"userinfo": self._userinfo, "access_token": "at", "scope": "openid email profile"} def test_a_google_sign_in_stamps_last_login(client, db, make_user, monkeypatch): from cryptography.fernet import Fernet from app import security # The callback stores the OAuth grant encrypted; a throwaway key stands in for TOKEN_ENCRYPTION_KEY. monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key())) user = make_user(email="g@example.com", password=None, google_sub="sub-g") monkeypatch.setattr(settings, "admin_emails", "g@example.com") # the bootstrap allow-list fake = _FakeGoogle({"sub": "sub-g", "email": "g@example.com", "email_verified": True}) monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: fake) r = client.get("/auth/callback", follow_redirects=False) assert r.status_code in (302, 307) and r.headers["location"] == "/" db.refresh(user) assert _recent(user.last_login_at) # …and it is a real sign-in, not only a stamp: the session now answers as this account. assert client.get("/api/me").json()["id"] == user.id def test_the_demo_entry_does_not_stamp_the_shared_account(client, db): db.add(DemoWhitelist(email="visitor@example.com")) db.commit() r = client.post("/auth/demo", json={"email": "visitor@example.com"}) assert r.json() == {"authenticated": True} demo = auth_routes.get_or_create_demo_user(db) db.refresh(demo) assert demo.last_login_at is None def test_the_admin_user_list_carries_last_login(as_user, db, make_user): admin = make_user(email="boss@example.com", role="admin") never = make_user(email="never@example.com") admin.last_login_at = datetime(2026, 9, 16, 8, 30, tzinfo=timezone.utc) db.commit() rows = {u["email"]: u for u in as_user(admin).get("/api/admin/users").json()} assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30") assert rows[never.email]["last_login_at"] is None def test_the_account_sees_its_own_last_sign_in(client, db, make_user): # Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did). make_user(email="me@example.com", password=_PW) client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW}) stamp = client.get("/api/me").json()["last_login_at"] assert _recent(datetime.fromisoformat(stamp))