495 lines
19 KiB
Python
495 lines
19 KiB
Python
"""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"] == []
|