Coverage for app / backend / src / tests / test_verification.py: 99%
164 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
1from unittest.mock import Mock, patch
3import grpc
4import pytest
5from google.protobuf import empty_pb2
6from sqlalchemy import select, update
8import couchers.phone.sms
9from couchers.config import config
10from couchers.crypto import random_hex
11from couchers.db import session_scope
12from couchers.models import SMS, User
13from couchers.proto import account_pb2, api_pb2
14from couchers.utils import now
15from tests.fixtures.db import generate_user
16from tests.fixtures.misc import PushCollector, process_jobs
17from tests.fixtures.sessions import account_session, api_session
20@pytest.fixture(autouse=True)
21def _(testconfig):
22 pass
25def test_ChangePhone(db, monkeypatch, push_collector: PushCollector):
26 user, token = generate_user()
27 user_id = user.id
29 with account_session(token) as account:
30 res = account.GetAccountInfo(empty_pb2.Empty())
31 assert res.phone == ""
33 monkeypatch.setattr(couchers.phone.sms, "send_sms", pytest.fail)
35 # Try with a too long number
36 with pytest.raises(grpc.RpcError) as e:
37 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+4670174060666666"))
38 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
40 # try to see if one digit too much is caught before attempting to send sms
41 with pytest.raises(grpc.RpcError) as e:
42 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+467017406066"))
43 assert e.value.code() == grpc.StatusCode.UNIMPLEMENTED
45 # Test with operator not supported by SMS backend
46 def deny_operator(phone, message):
47 assert phone == "+46701740605"
48 return "unsupported operator"
50 monkeypatch.setattr(couchers.phone.sms, "send_sms", deny_operator)
52 with pytest.raises(grpc.RpcError) as e:
53 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+46701740605"))
54 assert e.value.code() == grpc.StatusCode.UNIMPLEMENTED
56 # Test with successfully sent SMS
57 def succeed(phone, message):
58 assert phone == "+46701740605"
59 return "success"
61 assert push_collector.count_for_user(user_id) == 0
63 monkeypatch.setattr(couchers.phone.sms, "send_sms", succeed)
65 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+46701740605"))
67 with session_scope() as session:
68 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
69 assert user.phone == "+46701740605"
70 assert user.phone_verification_token
71 assert len(user.phone_verification_token) == 6
73 process_jobs()
74 push = push_collector.pop_for_user(user_id, last=True)
75 assert push.content.title == "Phone verification started"
76 assert push.content.body == "You started phone number verification with the number +46 70 174 06 05."
78 # Phone number should show up but not be verified in your profile settings
79 res = account.GetAccountInfo(empty_pb2.Empty())
80 assert res.phone == "+46701740605"
81 assert not res.phone_verified
83 # Remove phone number
84 account.ChangePhone(account_pb2.ChangePhoneReq(phone=""))
86 with session_scope() as session:
87 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
88 assert user.phone is None
89 assert user.phone_verification_token is None
92def test_ChangePhone_ratelimit(db, monkeypatch):
93 user, token = generate_user()
94 user_id = user.id
95 with account_session(token) as account:
97 def succeed(phone, message):
98 return "success"
100 monkeypatch.setattr(couchers.phone.sms, "send_sms", succeed)
102 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+46701740605"))
104 with pytest.raises(grpc.RpcError) as e:
105 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+46701740606"))
106 assert e.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
108 # Check that an earlier phone number/verification status is still saved
109 with session_scope() as session:
110 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
111 assert user.phone == "+46701740605"
112 assert user.phone_verification_token
113 assert len(user.phone_verification_token) == 6
116def test_VerifyPhone(push_collector: PushCollector):
117 user, token = generate_user()
118 with account_session(token) as account, api_session(token) as api:
119 with pytest.raises(grpc.RpcError) as e:
120 account.VerifyPhone(account_pb2.VerifyPhoneReq(token="123455"))
121 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
123 res = api.GetUser(api_pb2.GetUserReq(user=str(user.id)))
124 assert res.verification == 0.0
126 with session_scope() as session:
127 session.execute(
128 update(User)
129 .where(User.id == user.id)
130 .values(phone_verification_token="111112", phone_verification_sent=now(), phone="+46701740605")
131 )
133 account.VerifyPhone(account_pb2.VerifyPhoneReq(token="111112"))
135 process_jobs()
136 push = push_collector.pop_for_user(user.id, last=True)
137 assert push.content.title == "Phone verification completed"
138 assert push.content.body == "Your phone number was successfully verified as +46 70 174 06 05."
140 res = api.GetUser(api_pb2.GetUserReq(user=str(user.id)))
141 assert res.verification == 1.0
143 # Phone number should finally show up on in your profile settings
144 res = account.GetAccountInfo(empty_pb2.Empty())
145 assert res.phone == "+46701740605"
148def test_VerifyPhone_antibrute():
149 user, token = generate_user(
150 phone_verification_token="111112",
151 phone_verification_sent=now(),
152 phone="+46701740605",
153 )
155 with account_session(token) as account:
156 for _ in range(10): 156 ↛ 161line 156 didn't jump to line 161 because the loop on line 156 didn't complete
157 with pytest.raises(grpc.RpcError) as e:
158 account.VerifyPhone(account_pb2.VerifyPhoneReq(token="123455"))
159 if e.value.code() != grpc.StatusCode.NOT_FOUND:
160 break
161 assert e.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
164def test_phone_uniqueness(monkeypatch):
165 user1, token1 = generate_user()
166 user2, token2 = generate_user()
167 with account_session(token1) as account1, account_session(token2) as account2:
169 def succeed(phone, message):
170 return "success"
172 monkeypatch.setattr(couchers.phone.sms, "send_sms", succeed)
174 account1.ChangePhone(account_pb2.ChangePhoneReq(phone="+46701740605"))
175 with session_scope() as session:
176 token = session.execute(select(User.phone_verification_token).where(User.id == user1.id)).scalar_one()
177 account1.VerifyPhone(account_pb2.VerifyPhoneReq(token=token))
178 res = account1.GetAccountInfo(empty_pb2.Empty())
179 assert res.phone == "+46701740605"
180 assert res.phone_verified
182 # Let user2 steal user1:s phone number
184 account2.ChangePhone(account_pb2.ChangePhoneReq(phone="+46701740605"))
186 res = account1.GetAccountInfo(empty_pb2.Empty())
187 assert res.phone == "+46701740605"
188 assert res.phone_verified
190 res = account2.GetAccountInfo(empty_pb2.Empty())
191 assert res.phone == "+46701740605"
192 assert not res.phone_verified
194 with session_scope() as session:
195 token = session.execute(select(User.phone_verification_token).where(User.id == user2.id)).scalar_one()
196 account2.VerifyPhone(account_pb2.VerifyPhoneReq(token=token))
198 # number gets wiped when it's stolen
199 res = account1.GetAccountInfo(empty_pb2.Empty())
200 assert not res.phone
201 assert not res.phone_verified
203 res = account2.GetAccountInfo(empty_pb2.Empty())
204 assert res.phone == "+46701740605"
205 assert res.phone_verified
208def test_send_sms(db, monkeypatch):
209 new_config = config.copy()
210 new_config["ENABLE_SMS"] = True
211 new_config["SMS_SENDER_ID"] = "CouchersOrg"
212 monkeypatch.setattr(couchers.phone.sms, "config", new_config)
214 msg_id = random_hex()
216 with patch("couchers.phone.sms.boto3") as mock:
217 sns = Mock()
218 sns.publish.return_value = {"MessageId": msg_id}
219 mock.client.return_value = sns
221 assert couchers.phone.sms.send_sms("+46701740605", "Testing SMS message") == "success"
223 mock.client.assert_called_once_with("sns")
224 sns.publish.assert_called_once_with(
225 PhoneNumber="+46701740605",
226 Message="Testing SMS message",
227 MessageAttributes={
228 "AWS.SNS.SMS.SMSType": {"DataType": "String", "StringValue": "Transactional"},
229 "AWS.SNS.SMS.SenderID": {"DataType": "String", "StringValue": "CouchersOrg"},
230 },
231 )
233 with session_scope() as session:
234 sms = session.execute(select(SMS)).scalar_one()
235 assert sms.message_id == msg_id
236 assert sms.sms_sender_id == "CouchersOrg"
237 assert sms.number == "+46701740605"
238 assert sms.message == "Testing SMS message"
241def test_send_sms_disabled(db):
242 assert not config["ENABLE_SMS"]
243 assert couchers.phone.sms.send_sms("+46701740605", "Testing SMS message") == "SMS not enabled."
246def test_sms_verification_no_donation():
247 user, token = generate_user(last_donated=None)
248 with account_session(token) as account:
249 with pytest.raises(grpc.RpcError) as e:
250 account.ChangePhone(account_pb2.ChangePhoneReq(phone="+467017406066"))
251 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
252 assert e.value.details() == "Please complete donation to get phone verified."