tests/test_auth.py (5367 bytes)
1 """Email sign-in links.""" 2 3 from __future__ import annotations 4 5 from datetime import timedelta 6 from urllib.parse import parse_qs, urlparse 7 8 import pytest 9 10 from backend import mailer 11 from backend.db import JobStatus, LoginToken, SessionLocal, utcnow 12 from backend.settings import settings 13 14 from .conftest import account_id, get_job_row, make_job 15 16 17 @pytest.fixture 18 def outbox(monkeypatch): 19 """Pretend SMTP is configured, and catch what would have been sent.""" 20 sent = [] 21 monkeypatch.setattr(settings, "smtp_host", "smtp.example.test") 22 monkeypatch.setattr(mailer, "send_login_link", 23 lambda to, url: sent.append((to, url)) or True) 24 return sent 25 26 27 def token_of(url: str) -> str: 28 return parse_qs(urlparse(url).query)["login"][0] 29 30 31 def sign_in(client, outbox, email): 32 assert client.post("/api/auth/request", json={"email": email}).status_code == 200 33 return client.post("/api/auth/verify", json={"token": token_of(outbox[-1][1])}) 34 35 36 def test_link_signs_the_browser_in(client, outbox): 37 r = client.post("/api/auth/request", json={"email": " New@Example.com "}) 38 assert r.status_code == 200 39 assert r.json() == {"sent": True, "email": "new@example.com"} # no link leaked 40 to, url = outbox[-1] 41 assert to == "new@example.com" 42 assert url.startswith("https://example.test/?login=") 43 44 r = client.post("/api/auth/verify", json={"token": token_of(url)}) 45 assert r.status_code == 200 46 assert r.json()["signed_in"] is True and r.json()["email"] == "new@example.com" 47 assert client.get("/api/account").json()["signed_in"] is True 48 49 50 def test_link_works_once(client, outbox): 51 assert sign_in(client, outbox, "once@example.com").status_code == 200 52 again = client.post("/api/auth/verify", json={"token": token_of(outbox[-1][1])}) 53 assert again.status_code == 400 54 55 56 def test_expired_link_is_refused(client, outbox): 57 client.post("/api/auth/request", json={"email": "late@example.com"}) 58 with SessionLocal() as s: 59 row = s.query(LoginToken).filter(LoginToken.email == "late@example.com").one() 60 row.expires_at = utcnow() - timedelta(minutes=1) 61 s.commit() 62 r = client.post("/api/auth/verify", json={"token": token_of(outbox[-1][1])}) 63 assert r.status_code == 400 64 assert client.get("/api/account").json()["signed_in"] is False 65 66 67 def test_garbage_token_is_refused(client): 68 assert client.post("/api/auth/verify", json={"token": "nope"}).status_code == 400 69 70 71 def test_bad_email_is_refused(client, outbox): 72 for bad in ["", "no-at-sign", "a@b", "two words@x.com"]: 73 assert client.post("/api/auth/request", json={"email": bad}).status_code == 400 74 assert outbox == [] 75 76 77 def test_second_device_lands_in_the_same_account_and_keeps_its_work( 78 client, second_client, outbox 79 ): 80 assert sign_in(client, outbox, "both@example.com").status_code == 200 81 original = account_id(client) 82 83 job_id = make_job(second_client, JobStatus.succeeded) 84 assert sign_in(second_client, outbox, "both@example.com").status_code == 200 85 86 assert account_id(second_client) == original 87 assert get_job_row(job_id).account_id == original 88 89 90 def test_sign_out_forgets_the_browser_but_not_the_account(client, outbox): 91 assert sign_in(client, outbox, "bye@example.com").status_code == 200 92 original = account_id(client) 93 assert client.post("/api/auth/signout").status_code == 200 94 95 fresh = client.get("/api/account").json() 96 assert fresh["signed_in"] is False and fresh["id"] != original 97 98 assert sign_in(client, outbox, "bye@example.com").status_code == 200 99 assert account_id(client) == original 100 101 102 def test_signing_in_as_someone_else_switches_rather_than_merges(client, outbox): 103 assert sign_in(client, outbox, "first@example.com").status_code == 200 104 first = account_id(client) 105 job_id = make_job(client, JobStatus.succeeded) 106 107 assert sign_in(client, outbox, "second@example.com").status_code == 200 108 assert account_id(client) != first 109 assert get_job_row(job_id).account_id == first # stayed with its owner 110 111 112 def test_requests_are_rate_limited(client, outbox): 113 codes = [ 114 client.post("/api/auth/request", json={"email": "flood@example.com"}).status_code 115 for _ in range(7) 116 ] 117 assert codes[:5] == [200] * 5 and set(codes[5:]) == {429} 118 119 120 @pytest.mark.parametrize("billing", [True, False]) 121 def test_server_without_smtp_refuses_instead_of_leaking_the_link( 122 client, monkeypatch, billing 123 ): 124 # No mail server: showing the link instead would let anyone sign in as 125 # anyone. Billing being off must not soften this - a public server can 126 # perfectly well have billing off. 127 monkeypatch.setattr(settings, "billing_enabled", billing) 128 r = client.post("/api/auth/request", json={"email": "x@example.com"}) 129 assert r.status_code == 503 and "dev_link" not in r.text 130 assert client.get("/api/account").json()["email_sign_in_available"] is False 131 132 133 def test_dev_mode_hands_the_link_back_only_when_asked_to(client, monkeypatch): 134 monkeypatch.setattr(settings, "dev_login_links", True) 135 r = client.post("/api/auth/request", json={"email": "dev@example.com"}) 136 assert r.status_code == 200 and r.json()["sent"] is False 137 link = r.json()["dev_link"] 138 assert client.post("/api/auth/verify", 139 json={"token": token_of(link)}).json()["signed_in"] is True