Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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