"""End-to-end integration tests for the v0.11.0 trust-device vertical (§6.2, roadmap item #9). After a successful OTC or passcode sign-in with `trust_device=true` on the body, the server mints a fresh `device_trust` row and sets the `rfc_device_trust` cookie. On a subsequent visit, the cookie carries a session re-established by `POST /auth/device-trust/start`. The tests below prove: * `trust_device=false` (default, including omitted) on OTC verify does NOT set the device-trust cookie and does NOT insert a row. * `trust_device=true` on OTC verify DOES set the cookie (HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row. The row's hash is NOT the raw token; only the hash lives in the database. * Same shape for passcode verify. * On a returning visit with the cookie, `POST /auth/device-trust/start` re-establishes the session — `GET /api/auth/me` reads the right user without an OTC roundtrip. * `last_seen_at` refreshes on a successful lookup. * `POST /auth/device-trust/start` with no cookie returns 401. * `POST /auth/device-trust/start` with a forged / unknown cookie returns 401 + clears the cookie. * A revoked row refuses the cookie (401) and clears it. * An expired row refuses the cookie (401) and clears it. * `GET /api/auth/me/devices` lists the user's active rows. * `DELETE /api/auth/me/devices/{id}` revokes a single row. * `DELETE /api/auth/me/devices/{id}` for another user's row reads 404. * `DELETE /api/auth/me/devices` revokes every active row. * Constant-time path: bcrypt.checkpw guards lookup; the raw token is never written to logs or to the DB. The fakes from `test_propose_vertical` give us a working app harness. The OTC envelope buffer from `test_otc_vertical` is reused for the OTC roundtrips this suite needs. """ from __future__ import annotations import pytest from test_propose_vertical import ( # noqa: F401 FakeGitea, app_with_fake_gitea, provision_user_row, tmp_env, ) # --------------------------------------------------------------------------- # Helpers — mirror the OTC suite's outbound-buffer helpers. # --------------------------------------------------------------------------- COOKIE_NAME = "rfc_device_trust" # The device-trust cookie is set with Secure=True, which httpx (the # TestClient's underlying transport) will only return on an https # scheme. We use a `base_url="https://testserver"` so the cookie # roundtrips faithfully — that mirrors how production deployments # serve the framework (per the v0.11.0 upgrade-step requiring HTTPS). HTTPS_BASE = "https://testserver" def _reset_outbound(): from app import email as email_mod email_mod.reset_sent_envelopes() def _outbound_otc_codes(to_address: str | None = None) -> list[str]: from app import email as email_mod out = [] for env in email_mod.sent_envelopes(): if env.get("kind") != "otc": continue if to_address is not None and env["to"] != to_address: continue for line in env["body"].splitlines(): tok = line.strip() if tok.isdigit() and len(tok) == 6: out.append(tok) break return out def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None: r = client.post("/auth/otc/request", json={"email": email}) assert r.status_code == 200, r.text code = _outbound_otc_codes(email)[-1] body = {"email": email, "code": code, "trust_device": trust_device} r = client.post("/auth/otc/verify", json=body) assert r.status_code == 200, r.text def _device_rows_for_email(email: str) -> list[dict]: from app import db rows = db.conn().execute( """ SELECT dt.* FROM device_trust dt JOIN users u ON u.id = dt.user_id WHERE u.email = ? COLLATE NOCASE ORDER BY dt.id """, (email,), ).fetchall() return [dict(r) for r in rows] # --------------------------------------------------------------------------- # trust_device flag controls cookie issuance # --------------------------------------------------------------------------- def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() _sign_in_via_otc(client, "alice@example.com", trust_device=False) # No cookie set on the response. assert COOKIE_NAME not in {c.name for c in client.cookies.jar} # No row inserted. assert _device_rows_for_email("alice@example.com") == [] def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"}, ) assert r.status_code == 200, r.text # Cookie present on the response. set_cookie = r.headers.get("set-cookie", "") assert COOKIE_NAME in set_cookie # Cookie attribute set asserts the spec'd shape. Starlette emits # the attribute names case-insensitively (`samesite=lax`, # `httponly`); we normalize when asserting. lower = set_cookie.lower() assert "httponly" in lower assert "secure" in lower assert "samesite=lax" in lower assert "max-age=" in lower # Row inserted; hash is not the raw token. rows = _device_rows_for_email("alice@example.com") assert len(rows) == 1 row = rows[0] assert row["revoked_at"] is None assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)" cookie_token = client.cookies.get(COOKIE_NAME) assert cookie_token assert cookie_token != row["device_token_hash"] # bcrypt hash shape (starts with $2) assert row["device_token_hash"].startswith("$2") def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() _sign_in_via_otc(client, "alice@example.com") # Set a passcode. r = client.post("/auth/passcode/set", json={"passcode": "secret123"}) assert r.status_code == 200, r.text # Sign out so the passcode verify path is the active sign-in. client.cookies.clear() # Passcode verify with trust_device=true issues a row. r = client.post( "/auth/passcode/verify", json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True}, headers={"User-Agent": "Test/Phone"}, ) assert r.status_code == 200, r.text set_cookie = r.headers.get("set-cookie", "") assert COOKIE_NAME in set_cookie rows = _device_rows_for_email("alice@example.com") assert len(rows) == 1 assert rows[0]["user_agent"] == "Test/Phone" # --------------------------------------------------------------------------- # /auth/device-trust/start # --------------------------------------------------------------------------- def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: r = client.post("/auth/device-trust/start") assert r.status_code == 401 def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() # Trust the device. r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, ) assert r.status_code == 200, r.text trust_cookie = client.cookies.get(COOKIE_NAME) assert trust_cookie # Clear the session cookie so only the device-trust cookie is in # play. We keep `rfc_device_trust` and drop `rfc_session`. for cookie in list(client.cookies.jar): if cookie.name != COOKIE_NAME: client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name) # The session cookie is gone — /api/auth/me reads anonymous. me = client.get("/api/auth/me").json() assert me["authenticated"] is False # Hit the trust-start endpoint; the cookie re-establishes the session. r = client.post("/auth/device-trust/start") assert r.status_code == 200, r.text assert r.json()["user"]["email"] == "alice@example.com" # /api/auth/me now reads authenticated. me = client.get("/api/auth/me").json() assert me["authenticated"] is True assert me["user"]["email"] == "alice@example.com" def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, ) assert r.status_code == 200, r.text # Force the existing row's last_seen_at into the past so we can # assert the refresh moved it forward. db.conn().execute( """ UPDATE device_trust SET last_seen_at = datetime('now', '-7 days') """ ) # Hit the start endpoint. r = client.post("/auth/device-trust/start") assert r.status_code == 200, r.text # last_seen_at is now recent (within the last minute). row = db.conn().execute( "SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1" ).fetchone() assert row["fresh"] == 1 def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, ) assert r.status_code == 200, r.text # Revoke the row out-of-band. db.conn().execute( "UPDATE device_trust SET revoked_at = datetime('now')" ) # Now the start endpoint refuses + clears the cookie. r = client.post("/auth/device-trust/start") assert r.status_code == 401 # The cookie is cleared via a Set-Cookie header with Max-Age=0 # (Starlette's `delete_cookie` shape). set_cookie = r.headers.get("set-cookie", "") assert COOKIE_NAME in set_cookie assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu") def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, ) assert r.status_code == 200, r.text # Backdate the expiry into the past. db.conn().execute( "UPDATE device_trust SET expires_at = datetime('now', '-1 day')" ) r = client.post("/auth/device-trust/start") assert r.status_code == 401 set_cookie = r.headers.get("set-cookie", "") assert COOKIE_NAME in set_cookie def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: # No real row; just paste a cookie value. client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx") r = client.post("/auth/device-trust/start") assert r.status_code == 401 set_cookie = r.headers.get("set-cookie", "") assert COOKIE_NAME in set_cookie # --------------------------------------------------------------------------- # /api/auth/me/devices — list + revoke # --------------------------------------------------------------------------- def test_list_devices_requires_session(app_with_fake_gitea): from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: r = client.get("/api/auth/me/devices") assert r.status_code == 401 def test_list_devices_returns_active_rows_only(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() _sign_in_via_otc(client, "alice@example.com", trust_device=True) # Add a second trusted device by re-running the verify flow. # OTC has a per-email cooldown, so drop the cooldown rather # than waiting it out. db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')") r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, headers={"User-Agent": "Test/Tablet"}, ) assert r.status_code == 200, r.text # Revoke one row directly. db.conn().execute( "UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1" ) # /api/auth/me/devices returns only the un-revoked one. r = client.get("/api/auth/me/devices") assert r.status_code == 200, r.text items = r.json()["items"] assert len(items) == 1 assert items[0]["user_agent"] == "Test/Tablet" def test_revoke_single_device_kills_the_row(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() _sign_in_via_otc(client, "alice@example.com", trust_device=True) r = client.get("/api/auth/me/devices") assert r.status_code == 200, r.text items = r.json()["items"] assert len(items) == 1 device_id = items[0]["id"] # Revoke it. r = client.delete(f"/api/auth/me/devices/{device_id}") assert r.status_code == 200, r.text # List is empty. r = client.get("/api/auth/me/devices") assert r.json()["items"] == [] # The row in the table has revoked_at populated. row = db.conn().execute( "SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,) ).fetchone() assert row["revoked_at"] is not None def test_revoke_other_users_device_reads_404(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() # Alice trusts a device. _sign_in_via_otc(client, "alice@example.com", trust_device=True) alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"] # Bob signs in (without a trusted device of his own). client.cookies.clear() db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')") _sign_in_via_otc(client, "bob@example.com", trust_device=False) # Bob tries to revoke Alice's row by id. r = client.delete(f"/api/auth/me/devices/{alice_device_id}") assert r.status_code == 404 # Alice's row is still active. row = db.conn().execute( "SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,) ).fetchone() assert row["revoked_at"] is None def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea): from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app, base_url=HTTPS_BASE) as client: _reset_outbound() _sign_in_via_otc(client, "alice@example.com", trust_device=True) # Add a second device. db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')") r = client.post("/auth/otc/request", json={"email": "alice@example.com"}) assert r.status_code == 200, r.text code = _outbound_otc_codes("alice@example.com")[-1] r = client.post( "/auth/otc/verify", json={"email": "alice@example.com", "code": code, "trust_device": True}, ) assert r.status_code == 200, r.text # Two active rows. assert len(client.get("/api/auth/me/devices").json()["items"]) == 2 # Revoke all. r = client.delete("/api/auth/me/devices") assert r.status_code == 200, r.text assert r.json()["revoked"] == 2 # List is empty. assert client.get("/api/auth/me/devices").json()["items"] == []