Coverage for app/backend/src/tests/test_auth.py: 100%
1056 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
1import http.cookies
2from datetime import timedelta
3from typing import Any, cast
4from unittest.mock import DEFAULT, patch
6import grpc
7import pytest
8from google.protobuf import empty_pb2, wrappers_pb2
9from sqlalchemy import event, select, update
10from sqlalchemy.sql import delete, func
12from couchers import urls
13from couchers.context import CouchersContext
14from couchers.crypto import hash_password, random_hex
15from couchers.db import _get_base_engine, session_scope
16from couchers.models import (
17 ContributeOption,
18 ContributorForm,
19 LoginToken,
20 ModerationObjectType,
21 ModerationState,
22 NonvisibleUserAccess,
23 NonvisibleUserAccessType,
24 NonvisibleUserState,
25 PasswordResetToken,
26 SignupFlow,
27 User,
28 UserSession,
29)
30from couchers.proto import account_pb2, api_pb2, auth_pb2
31from couchers.servicers.auth import create_session
32from couchers.utils import now
33from tests.fixtures.db import generate_user
34from tests.fixtures.misc import EmailCollector, PushCollector
35from tests.fixtures.sessions import (
36 MetadataKeeperInterceptor,
37 _MockCouchersContext,
38 account_session,
39 api_session,
40 auth_api_session,
41 real_api_session,
42)
45@pytest.fixture(autouse=True)
46def _(fast_passwords):
47 pass
50def get_session_cookie_tokens(metadata_interceptor: MetadataKeeperInterceptor) -> tuple[str, str]:
51 set_cookies = [val for key, val in metadata_interceptor.latest_header_raw if key == "set-cookie"]
52 sesh = http.cookies.SimpleCookie([v for v in set_cookies if "sesh" in v][0])["couchers-sesh"].value
53 uid = http.cookies.SimpleCookie([v for v in set_cookies if "user-id" in v][0])["couchers-user-id"].value
54 return sesh, uid
57def test_UsernameValid(db):
58 with auth_api_session() as (auth_api, metadata_interceptor):
59 assert auth_api.UsernameValid(auth_pb2.UsernameValidReq(username="test")).valid
61 with auth_api_session() as (auth_api, metadata_interceptor):
62 assert not auth_api.UsernameValid(auth_pb2.UsernameValidReq(username="")).valid
65def test_signup_incremental(db):
66 with auth_api_session() as (auth_api, metadata_interceptor):
67 res = auth_api.SignupFlow(
68 auth_pb2.SignupFlowReq(
69 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
70 )
71 )
73 flow_token = res.flow_token
74 assert res.flow_token
75 assert not res.HasField("auth_res")
76 assert res.email == "email@couchers.org.invalid"
77 assert not res.need_basic
78 assert res.need_account
79 assert not res.need_feedback
80 assert res.need_verify_email
81 assert res.need_accept_community_guidelines
82 assert res.need_motivations
84 # read out the signup token directly from the database for now
85 with session_scope() as session:
86 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
87 assert flow.email_sent
88 assert not flow.email_verified
89 email_token = flow.email_token
91 with auth_api_session() as (auth_api, metadata_interceptor):
92 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(flow_token=flow_token))
94 assert res.flow_token == flow_token
95 assert not res.HasField("auth_res")
96 assert res.email == "email@couchers.org.invalid"
97 assert not res.need_basic
98 assert res.need_account
99 assert not res.need_feedback
100 assert res.need_verify_email
101 assert res.need_accept_community_guidelines
102 assert res.need_motivations
104 # Add feedback
105 with auth_api_session() as (auth_api, metadata_interceptor):
106 res = auth_api.SignupFlow(
107 auth_pb2.SignupFlowReq(
108 flow_token=flow_token,
109 feedback=auth_pb2.ContributorForm(
110 ideas="I'm a robot, incapable of original ideation",
111 features="I love all your features",
112 experience="I haven't done couch surfing before",
113 contribute=auth_pb2.CONTRIBUTE_OPTION_YES,
114 contribute_ways=["serving", "backend"],
115 expertise="I'd love to be your server: I can compute very fast, but only simple opcodes",
116 ),
117 )
118 )
120 assert res.flow_token == flow_token
121 assert not res.HasField("auth_res")
122 assert res.email == "email@couchers.org.invalid"
123 assert not res.need_basic
124 assert res.need_account
125 assert not res.need_feedback
126 assert res.need_verify_email
127 assert res.need_accept_community_guidelines
128 assert res.need_motivations
130 # Agree to community guidelines
131 with auth_api_session() as (auth_api, metadata_interceptor):
132 res = auth_api.SignupFlow(
133 auth_pb2.SignupFlowReq(
134 flow_token=flow_token,
135 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
136 )
137 )
139 assert res.flow_token == flow_token
140 assert not res.HasField("auth_res")
141 assert res.email == "email@couchers.org.invalid"
142 assert not res.need_basic
143 assert res.need_account
144 assert not res.need_feedback
145 assert res.need_verify_email
146 assert not res.need_accept_community_guidelines
147 assert res.need_motivations
149 # Submit motivations
150 with auth_api_session() as (auth_api, metadata_interceptor):
151 res = auth_api.SignupFlow(
152 auth_pb2.SignupFlowReq(
153 flow_token=flow_token,
154 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
155 )
156 )
158 assert res.flow_token == flow_token
159 assert not res.HasField("auth_res")
160 assert res.email == "email@couchers.org.invalid"
161 assert not res.need_basic
162 assert res.need_account
163 assert not res.need_feedback
164 assert res.need_verify_email
165 assert not res.need_accept_community_guidelines
166 assert not res.need_motivations
168 # Verify email
169 with auth_api_session() as (auth_api, metadata_interceptor):
170 res = auth_api.SignupFlow(
171 auth_pb2.SignupFlowReq(
172 flow_token=flow_token,
173 email_token=email_token,
174 )
175 )
177 assert res.flow_token == flow_token
178 assert not res.HasField("auth_res")
179 assert res.email == "email@couchers.org.invalid"
180 assert not res.need_basic
181 assert res.need_account
182 assert not res.need_feedback
183 assert not res.need_verify_email
184 assert not res.need_accept_community_guidelines
185 assert not res.need_motivations
187 # Finally finish off account info
188 with auth_api_session() as (auth_api, metadata_interceptor):
189 res = auth_api.SignupFlow(
190 auth_pb2.SignupFlowReq(
191 flow_token=flow_token,
192 account=auth_pb2.SignupAccount(
193 username="frodo",
194 password="a very insecure password",
195 birthdate="1970-01-01",
196 gender="Bot",
197 hosting_status=api_pb2.HOSTING_STATUS_MAYBE,
198 city="New York City",
199 lat=40.7331,
200 lng=-73.9778,
201 radius=500,
202 accept_tos=True,
203 ),
204 )
205 )
207 assert not res.flow_token
208 assert res.HasField("auth_res")
209 assert not res.email
210 assert res.auth_res.user_id
211 assert not res.auth_res.jailed
212 assert not res.need_basic
213 assert not res.need_account
214 assert not res.need_feedback
215 assert not res.need_verify_email
216 assert not res.need_accept_community_guidelines
217 assert not res.need_motivations
219 user_id = res.auth_res.user_id
221 sess_token, uid = get_session_cookie_tokens(metadata_interceptor)
222 assert uid == str(user_id)
224 with api_session(sess_token) as api:
225 res = api.GetUser(api_pb2.GetUserReq(user=str(user_id)))
227 assert res.username == "frodo"
228 assert res.gender == "Bot"
229 assert res.hosting_status == api_pb2.HOSTING_STATUS_MAYBE
230 assert res.city == "New York City"
231 assert res.lat == 40.7331
232 assert res.lng == -73.9778
233 assert res.radius == 500
235 with session_scope() as session:
236 form = session.execute(select(ContributorForm)).scalar_one()
238 assert form.ideas == "I'm a robot, incapable of original ideation"
239 assert form.features == "I love all your features"
240 assert form.experience == "I haven't done couch surfing before"
241 assert form.contribute == ContributeOption.yes
242 assert form.contribute_ways == ["serving", "backend"]
243 assert form.expertise == "I'd love to be your server: I can compute very fast, but only simple opcodes"
246def test_signup_funnel_counters(db):
247 """Each per-step signup funnel counter should fire exactly once across an incremental signup."""
248 with patch.multiple(
249 "couchers.servicers.auth",
250 signup_initiations_counter=DEFAULT,
251 signup_account_filled_counter=DEFAULT,
252 signup_email_verified_counter=DEFAULT,
253 signup_guidelines_accepted_counter=DEFAULT,
254 signup_motivations_filled_counter=DEFAULT,
255 signup_completions_counter=DEFAULT,
256 ) as counters:
257 with auth_api_session() as (auth_api, metadata_interceptor):
258 res = auth_api.SignupFlow(
259 auth_pb2.SignupFlowReq(
260 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
261 )
262 )
263 flow_token = res.flow_token
264 counters["signup_initiations_counter"].inc.assert_called_once()
266 with session_scope() as session:
267 email_token = (
268 session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one().email_token
269 )
271 with auth_api_session() as (auth_api, metadata_interceptor):
272 auth_api.SignupFlow(
273 auth_pb2.SignupFlowReq(
274 flow_token=flow_token,
275 account=auth_pb2.SignupAccount(
276 username="frodo",
277 password="a very insecure password",
278 birthdate="1970-01-01",
279 gender="Bot",
280 hosting_status=api_pb2.HOSTING_STATUS_MAYBE,
281 city="New York City",
282 lat=40.7331,
283 lng=-73.9778,
284 radius=500,
285 accept_tos=True,
286 ),
287 )
288 )
289 counters["signup_account_filled_counter"].inc.assert_called_once()
291 # accept the guidelines twice; the counter must still only fire once
292 with auth_api_session() as (auth_api, metadata_interceptor):
293 auth_api.SignupFlow(
294 auth_pb2.SignupFlowReq(
295 flow_token=flow_token,
296 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
297 )
298 )
299 with auth_api_session() as (auth_api, metadata_interceptor):
300 auth_api.SignupFlow(
301 auth_pb2.SignupFlowReq(
302 flow_token=flow_token,
303 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
304 )
305 )
306 counters["signup_guidelines_accepted_counter"].inc.assert_called_once()
308 with auth_api_session() as (auth_api, metadata_interceptor):
309 auth_api.SignupFlow(
310 auth_pb2.SignupFlowReq(
311 flow_token=flow_token,
312 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
313 )
314 )
315 counters["signup_motivations_filled_counter"].inc.assert_called_once()
317 with auth_api_session() as (auth_api, metadata_interceptor):
318 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(flow_token=flow_token, email_token=email_token))
319 counters["signup_email_verified_counter"].inc.assert_called_once()
321 assert res.HasField("auth_res")
322 counters["signup_completions_counter"].labels.assert_called_once_with("Bot")
325def _quick_signup() -> int:
326 with auth_api_session() as (auth_api, metadata_interceptor):
327 res = auth_api.SignupFlow(
328 auth_pb2.SignupFlowReq(
329 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
330 account=auth_pb2.SignupAccount(
331 username="frodo",
332 password="a very insecure password",
333 birthdate="1970-01-01",
334 gender="Bot",
335 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
336 city="New York City",
337 lat=40.7331,
338 lng=-73.9778,
339 radius=500,
340 accept_tos=True,
341 ),
342 feedback=auth_pb2.ContributorForm(),
343 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
344 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
345 )
346 )
348 flow_token = res.flow_token
350 assert res.flow_token
351 assert not res.HasField("auth_res")
352 assert not res.need_basic
353 assert not res.need_account
354 assert not res.need_feedback
355 assert not res.need_motivations
356 assert res.need_verify_email
358 # read out the signup token directly from the database for now
359 with session_scope() as session:
360 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
361 assert flow.email_sent
362 assert not flow.email_verified
363 email_token = flow.email_token
365 with auth_api_session() as (auth_api, metadata_interceptor):
366 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
368 assert not res.flow_token
369 assert res.HasField("auth_res")
370 assert res.auth_res.user_id
371 assert not res.auth_res.jailed
372 assert not res.need_basic
373 assert not res.need_account
374 assert not res.need_feedback
375 assert not res.need_motivations
376 assert not res.need_verify_email
378 # make sure we got the right token in a cookie
379 with session_scope() as session:
380 token = session.execute(
381 select(UserSession.token).join(User, UserSession.user_id == User.id).where(User.username == "frodo")
382 ).scalar_one()
383 sesh, uid = get_session_cookie_tokens(metadata_interceptor)
384 assert sesh == token
386 return cast(int, res.auth_res.user_id)
389def test_signup(db):
390 _quick_signup()
393def test_signup_creates_user_moderation_state(db):
394 user_id = _quick_signup()
396 with session_scope() as session:
397 state = session.execute(
398 select(ModerationState)
399 .where(ModerationState.object_type == ModerationObjectType.user)
400 .where(ModerationState.object_id == user_id)
401 ).scalar_one()
402 assert state.visibility is None
403 assert session.execute(select(User.moderation_state_id).where(User.id == user_id)).scalar_one() == state.id
406def test_basic_login(db):
407 # Create our test user using signup
408 _quick_signup()
410 with auth_api_session() as (auth_api, metadata_interceptor):
411 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
413 reply_token, _ = get_session_cookie_tokens(metadata_interceptor)
415 with session_scope() as session:
416 token = session.execute(
417 select(UserSession.token)
418 .join(User, UserSession.user_id == User.id)
419 .where(User.username == "frodo")
420 .where(UserSession.token == reply_token)
421 .where(UserSession.is_valid)
422 ).scalar_one_or_none()
423 assert token
425 # log out
426 with auth_api_session() as (auth_api, metadata_interceptor):
427 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={reply_token}"),))
430def test_login_part_signed_up_verified_email(db):
431 """
432 If you try to log in but didn't finish signing up, we send you a new email and ask you to finish signing up.
433 """
434 with auth_api_session() as (auth_api, metadata_interceptor):
435 res = auth_api.SignupFlow(
436 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"))
437 )
439 flow_token = res.flow_token
440 assert res.need_verify_email
442 # verify the email
443 with session_scope() as session:
444 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
445 flow_token = flow.flow_token
446 email_token = flow.email_token
447 with auth_api_session() as (auth_api, metadata_interceptor):
448 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
450 with EmailCollector() as email_collector:
451 with auth_api_session() as (auth_api, metadata_interceptor):
452 with pytest.raises(grpc.RpcError) as err:
453 auth_api.Authenticate(auth_pb2.AuthReq(user="email@couchers.org.invalid", password="wrong pwd"))
454 assert err.value.details() == "Please check your email for a link to continue signing up."
456 email = email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
457 assert email.recipient == "email@couchers.org.invalid"
458 assert flow_token in email.plain
459 assert flow_token in email.html
462def test_login_part_signed_up_not_verified_email(db):
463 with auth_api_session() as (auth_api, metadata_interceptor):
464 res = auth_api.SignupFlow(
465 auth_pb2.SignupFlowReq(
466 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
467 account=auth_pb2.SignupAccount(
468 username="frodo",
469 password="a very insecure password",
470 birthdate="1999-01-01",
471 gender="Bot",
472 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
473 city="New York City",
474 lat=40.7331,
475 lng=-73.9778,
476 radius=500,
477 accept_tos=True,
478 ),
479 )
480 )
482 flow_token = res.flow_token
483 assert res.need_verify_email
485 with EmailCollector() as email_collector:
486 with auth_api_session() as (auth_api, metadata_interceptor):
487 with pytest.raises(grpc.RpcError) as err:
488 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="wrong pwd"))
489 assert err.value.details() == "Please check your email for a link to continue signing up."
491 with session_scope() as session:
492 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
493 email_token = flow.email_token
495 email = email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
496 assert email.recipient == "email@couchers.org.invalid"
497 assert email_token
498 assert email_token in email.plain
499 assert email_token in email.html
502def test_banned_user(db):
503 user_id = _quick_signup()
505 with session_scope() as session:
506 session.execute(select(User)).scalar_one().banned_at = now()
508 with auth_api_session() as (auth_api, metadata_interceptor):
509 with pytest.raises(grpc.RpcError) as e:
510 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
511 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
512 assert e.value.details() == "Your account is suspended."
514 with session_scope() as session:
515 access = session.execute(select(NonvisibleUserAccess)).scalar_one()
516 assert access.access_type == NonvisibleUserAccessType.login_attempt
517 assert access.target_state == NonvisibleUserState.banned
518 assert access.target_user_id == user_id
519 assert access.actor_user_id == user_id
522def test_shadowed_user_login_logged(db):
523 user_id = _quick_signup()
525 with session_scope() as session:
526 session.execute(select(User)).scalar_one().shadowed_at = now()
528 with auth_api_session() as (auth_api, metadata_interceptor):
529 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
531 with session_scope() as session:
532 access = session.execute(select(NonvisibleUserAccess)).scalar_one()
533 assert access.access_type == NonvisibleUserAccessType.login_attempt
534 assert access.target_state == NonvisibleUserState.shadowed
535 assert access.target_user_id == user_id
536 assert access.actor_user_id == user_id
539def test_deleted_user(db):
540 user_id = _quick_signup()
542 with session_scope() as session:
543 session.execute(update(User).where(User.id == user_id).values(deleted_at=func.now()))
545 with auth_api_session() as (auth_api, metadata_interceptor):
546 with pytest.raises(grpc.RpcError) as e:
547 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
548 assert e.value.code() == grpc.StatusCode.NOT_FOUND
549 assert e.value.details() == "An account with that username or email was not found."
552def test_invalid_token(db):
553 user1, token1 = generate_user()
554 user2, token2 = generate_user()
556 wrong_token = random_hex(32)
558 with real_api_session(wrong_token) as api, pytest.raises(grpc.RpcError) as e:
559 res = api.GetUser(api_pb2.GetUserReq(user=user2.username))
561 assert e.value.code() == grpc.StatusCode.UNAUTHENTICATED
562 assert e.value.details() == "Unauthorized"
565def test_password_reset_v2(db, email_collector: EmailCollector, push_collector: PushCollector):
566 user, token = generate_user(hashed_password=hash_password("mypassword"))
568 with auth_api_session() as (auth_api, metadata_interceptor):
569 res = auth_api.ResetPassword(auth_pb2.ResetPasswordReq(user=user.username))
571 with session_scope() as session:
572 password_reset_token = session.execute(select(PasswordResetToken.token)).scalar_one()
574 email = email_collector.pop_for_recipient(user.email, last=True)
575 assert email.recipient == user.email
576 assert "reset" in email.subject.lower()
577 assert password_reset_token in email.plain
578 assert password_reset_token in email.html
579 unique_string = "You asked for your password to be reset on Couchers.org."
580 assert unique_string in email.plain
581 assert unique_string in email.html
582 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in email.plain
583 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in email.html
584 assert "support@couchers.org" in email.plain
585 assert "support@couchers.org" in email.html
587 push = push_collector.pop_for_user(user.id, last=True)
588 assert push.content.title == "Password reset requested"
589 assert push.content.body == "Use the link we sent by email to complete it."
591 # make sure bad password are caught
592 with auth_api_session() as (auth_api, metadata_interceptor):
593 with pytest.raises(grpc.RpcError) as err:
594 auth_api.CompletePasswordResetV2(
595 auth_pb2.CompletePasswordResetV2Req(password_reset_token=password_reset_token, new_password="password")
596 )
597 assert err.value.code() == grpc.StatusCode.INVALID_ARGUMENT
598 assert err.value.details() == "The password is insecure. Please use one that is not easily guessable."
600 # make sure we can set a good password
601 with auth_api_session() as (auth_api, metadata_interceptor):
602 pwd = random_hex()
603 auth_api.CompletePasswordResetV2(
604 auth_pb2.CompletePasswordResetV2Req(password_reset_token=password_reset_token, new_password=pwd)
605 )
607 push = push_collector.pop_for_user(user.id, last=True)
608 assert push.content.title == "Password reset"
609 assert push.content.body == "Your password was successfully reset."
611 session_token, _ = get_session_cookie_tokens(metadata_interceptor)
613 with session_scope() as session:
614 other_session_token = session.execute(
615 select(UserSession.token)
616 .join(User, UserSession.user_id == User.id)
617 .where(User.username == user.username)
618 .where(UserSession.token == session_token)
619 .where(UserSession.is_valid)
620 ).scalar_one_or_none()
621 assert other_session_token
623 # make sure we can't set a password again
624 with auth_api_session() as (auth_api, metadata_interceptor):
625 with pytest.raises(grpc.RpcError) as err:
626 auth_api.CompletePasswordResetV2(
627 auth_pb2.CompletePasswordResetV2Req(
628 password_reset_token=password_reset_token, new_password=random_hex()
629 )
630 )
631 assert err.value.code() == grpc.StatusCode.NOT_FOUND
632 assert err.value.details() == "Invalid token."
634 with session_scope() as session:
635 user = session.execute(select(User)).scalar_one()
636 assert user.hashed_password == hash_password(pwd)
639def test_password_reset_no_such_user(db):
640 user, token = generate_user()
642 with auth_api_session() as (auth_api, metadata_interceptor):
643 res = auth_api.ResetPassword(
644 auth_pb2.ResetPasswordReq(
645 user="nonexistentuser",
646 )
647 )
649 with session_scope() as session:
650 assert session.execute(select(PasswordResetToken)).scalar_one_or_none() is None
653def test_password_reset_invalid_token_v2(db):
654 password = random_hex()
655 user, token = generate_user(hashed_password=hash_password(password))
657 with auth_api_session() as (auth_api, metadata_interceptor):
658 res = auth_api.ResetPassword(
659 auth_pb2.ResetPasswordReq(
660 user=user.username,
661 )
662 )
664 with auth_api_session() as (auth_api, metadata_interceptor), pytest.raises(grpc.RpcError) as e:
665 res = auth_api.CompletePasswordResetV2(auth_pb2.CompletePasswordResetV2Req(password_reset_token="wrongtoken"))
666 assert e.value.code() == grpc.StatusCode.NOT_FOUND
667 assert e.value.details() == "Invalid token."
669 with session_scope() as session:
670 user = session.execute(select(User)).scalar_one()
671 assert user.hashed_password == hash_password(password)
674def test_logout_invalid_token(db):
675 # Create our test user using signup
676 _quick_signup()
678 with auth_api_session() as (auth_api, metadata_interceptor):
679 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
681 reply_token, _ = get_session_cookie_tokens(metadata_interceptor)
683 # delete all login tokens
684 with session_scope() as session:
685 session.execute(delete(LoginToken))
687 # log out with non-existent token should still return a valid result
688 with auth_api_session() as (auth_api, metadata_interceptor):
689 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={reply_token}"),))
691 reply_token, _ = get_session_cookie_tokens(metadata_interceptor)
692 # make sure we set an empty cookie
693 assert reply_token == ""
696def test_signup_without_password(db):
697 with auth_api_session() as (auth_api, metadata_interceptor):
698 with pytest.raises(grpc.RpcError) as e:
699 auth_api.SignupFlow(
700 auth_pb2.SignupFlowReq(
701 basic=auth_pb2.SignupBasic(name="Räksmörgås", email="a1@b.com"),
702 account=auth_pb2.SignupAccount(
703 username="frodo",
704 password="bad",
705 city="Minas Tirith",
706 birthdate="9999-12-31", # arbitrary future birthdate
707 gender="Robot",
708 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
709 lat=1,
710 lng=1,
711 radius=100,
712 accept_tos=True,
713 ),
714 feedback=auth_pb2.ContributorForm(),
715 )
716 )
717 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
718 assert e.value.details() == "The password must be 8 or more characters long."
721def test_signup_invalid_birthdate(db):
722 with auth_api_session() as (auth_api, metadata_interceptor):
723 with pytest.raises(grpc.RpcError) as e:
724 auth_api.SignupFlow(
725 auth_pb2.SignupFlowReq(
726 basic=auth_pb2.SignupBasic(name="Räksmörgås", email="a1@b.com"),
727 account=auth_pb2.SignupAccount(
728 username="frodo",
729 password="a very insecure password",
730 city="Minas Tirith",
731 birthdate="9999-12-31", # arbitrary future birthdate
732 gender="Robot",
733 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
734 lat=1,
735 lng=1,
736 radius=100,
737 accept_tos=True,
738 ),
739 feedback=auth_pb2.ContributorForm(),
740 )
741 )
742 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
743 assert e.value.details() == "You must be at least 18 years old to sign up."
745 res = auth_api.SignupFlow(
746 auth_pb2.SignupFlowReq(
747 basic=auth_pb2.SignupBasic(name="Christopher", email="a2@b.com"),
748 account=auth_pb2.SignupAccount(
749 username="ceelo",
750 password="a very insecure password",
751 city="New York City",
752 birthdate="2000-12-31", # arbitrary birthdate older than 18 years
753 gender="Helicopter",
754 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
755 lat=1,
756 lng=1,
757 radius=100,
758 accept_tos=True,
759 ),
760 feedback=auth_pb2.ContributorForm(),
761 )
762 )
764 assert res.flow_token
766 with pytest.raises(grpc.RpcError) as e:
767 auth_api.SignupFlow(
768 auth_pb2.SignupFlowReq(
769 basic=auth_pb2.SignupBasic(name="Franklin", email="a3@b.com"),
770 account=auth_pb2.SignupAccount(
771 username="franklin",
772 password="a very insecure password",
773 city="Los Santos",
774 birthdate="2010-04-09", # arbitrary birthdate < 18 yrs
775 gender="Male",
776 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
777 lat=1,
778 lng=1,
779 radius=100,
780 accept_tos=True,
781 ),
782 feedback=auth_pb2.ContributorForm(),
783 )
784 )
785 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
786 assert e.value.details() == "You must be at least 18 years old to sign up."
788 with session_scope() as session:
789 assert session.execute(select(func.count()).select_from(SignupFlow)).scalar_one() == 1
792def test_signup_invalid_email(db):
793 with auth_api_session() as (auth_api, metadata_interceptor):
794 with pytest.raises(grpc.RpcError) as e:
795 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a")))
796 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
797 assert e.value.details() == "Invalid email."
799 with auth_api_session() as (auth_api, metadata_interceptor):
800 with pytest.raises(grpc.RpcError) as e:
801 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b")))
802 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
803 assert e.value.details() == "Invalid email."
805 with auth_api_session() as (auth_api, metadata_interceptor):
806 with pytest.raises(grpc.RpcError) as e:
807 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b.")))
808 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
809 assert e.value.details() == "Invalid email."
811 with auth_api_session() as (auth_api, metadata_interceptor):
812 with pytest.raises(grpc.RpcError) as e:
813 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b.c")))
814 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
815 assert e.value.details() == "Invalid email."
818def test_signup_existing_email(db):
819 # Signed up user
820 user, _ = generate_user()
822 with auth_api_session() as (auth_api, metadata_interceptor):
823 with pytest.raises(grpc.RpcError) as e:
824 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=user.email)))
825 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
826 assert e.value.details() == "That email address is already associated with an account. Please log in instead!"
829def test_signup_banned_user_email(db):
830 user, _ = generate_user()
832 with session_scope() as session:
833 session.execute(update(User).where(User.id == user.id).values(banned_at=func.now()))
835 with auth_api_session() as (auth_api, _):
836 with pytest.raises(grpc.RpcError) as e:
837 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email)))
838 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
839 assert e.value.details() == "You cannot sign up with that email address."
842def test_signup_deleted_user_email(db):
843 user, _ = generate_user(email="email@couchers.org.invalid")
845 with session_scope() as session:
846 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now()))
848 new_user_id = _quick_signup()
849 assert new_user_id != user.id
851 with auth_api_session() as (auth_api, _):
852 res = auth_api.Authenticate(auth_pb2.AuthReq(user=user.email, password="a very insecure password"))
853 assert res.user_id == new_user_id
856def test_signup_deleted_shadowed_user_email(db):
857 user, _ = generate_user(email="email@couchers.org.invalid")
859 with session_scope() as session:
860 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now(), shadowed_at=func.now()))
862 new_user_id = _quick_signup()
864 with session_scope() as session:
865 new_user = session.execute(select(User).where(User.id == new_user_id)).scalar_one()
866 assert new_user.shadowed_at is None
869def test_signup_deleted_banned_user_email(db):
870 user, _ = generate_user()
872 with session_scope() as session:
873 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now(), banned_at=func.now()))
875 with auth_api_session() as (auth_api, _):
876 with pytest.raises(grpc.RpcError) as e:
877 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email)))
878 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
879 assert e.value.details() == "You cannot sign up with that email address."
882def test_signup_shadowed_user_email(db):
883 user, _ = generate_user()
885 with session_scope() as session:
886 session.execute(update(User).where(User.id == user.id).values(shadowed_at=func.now()))
888 with auth_api_session() as (auth_api, _):
889 with pytest.raises(grpc.RpcError) as e:
890 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email)))
891 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
892 assert e.value.details() == "That email address is already associated with an account. Please log in instead!"
895def test_recover_account_email_reused(db):
896 user, _ = generate_user()
898 with session_scope() as session:
899 session.execute(
900 update(User)
901 .where(User.id == user.id)
902 .values(deleted_at=func.now(), undelete_token="token", undelete_until=now() + timedelta(days=1))
903 )
905 generate_user(email=user.email)
907 with auth_api_session() as (auth_api, _):
908 with pytest.raises(grpc.RpcError) as e:
909 auth_api.RecoverAccount(auth_pb2.RecoverAccountReq(token="token"))
910 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
911 assert (
912 e.value.details()
913 == "This account can't be recovered because its email address is now used by another account."
914 )
916 with session_scope() as session:
917 assert session.execute(select(User.deleted_at).where(User.id == user.id)).scalar_one() is not None
920def test_signup_continue_with_email(db):
921 testing_email = f"{random_hex(12)}@couchers.org.invalid"
922 with auth_api_session() as (auth_api, metadata_interceptor):
923 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)))
924 flow_token = res.flow_token
925 assert flow_token
927 # continue with same email, should just send another email to the user
928 with auth_api_session() as (auth_api, metadata_interceptor):
929 with pytest.raises(grpc.RpcError) as e:
930 res = auth_api.SignupFlow(
931 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email))
932 )
933 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
934 assert e.value.details() == "Please check your email for a link to continue signing up."
937def test_signup_resend_email(db, email_collector: EmailCollector):
938 with auth_api_session() as (auth_api, metadata_interceptor):
939 res = auth_api.SignupFlow(
940 auth_pb2.SignupFlowReq(
941 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
942 account=auth_pb2.SignupAccount(
943 username="frodo",
944 password="a very insecure password",
945 birthdate="1970-01-01",
946 gender="Bot",
947 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
948 city="New York City",
949 lat=40.7331,
950 lng=-73.9778,
951 radius=500,
952 accept_tos=True,
953 ),
954 feedback=auth_pb2.ContributorForm(),
955 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
956 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
957 )
958 )
960 email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
962 flow_token = res.flow_token
963 assert flow_token
965 with session_scope() as session:
966 flow = session.execute(select(SignupFlow)).scalar_one()
967 assert flow.flow_token == flow_token
968 assert flow.email_sent
969 assert not flow.email_verified
970 email_token = flow.email_token
972 # ask for a new signup email
973 with auth_api_session() as (auth_api, metadata_interceptor):
974 res = auth_api.SignupFlow(
975 auth_pb2.SignupFlowReq(
976 flow_token=flow_token,
977 resend_verification_email=True,
978 )
979 )
981 email = email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
982 assert email_token
983 assert email_token in email.plain
984 assert email_token in email.html
986 with session_scope() as session:
987 flow = session.execute(select(SignupFlow)).scalar_one()
988 assert not flow.email_verified
990 with auth_api_session() as (auth_api, metadata_interceptor):
991 res = auth_api.SignupFlow(
992 auth_pb2.SignupFlowReq(
993 email_token=email_token,
994 )
995 )
997 assert not res.flow_token
998 assert res.HasField("auth_res")
1001def test_signup_change_email(db, email_collector: EmailCollector):
1002 old_email = f"{random_hex(12)}@couchers.org.invalid"
1003 new_email = f"{random_hex(12)}@couchers.org.invalid"
1005 # Start a signup with the old email.
1006 with auth_api_session() as (auth_api, metadata_interceptor):
1007 res = auth_api.SignupFlow(
1008 auth_pb2.SignupFlowReq(
1009 basic=auth_pb2.SignupBasic(
1010 name="testing",
1011 email=old_email,
1012 )
1013 )
1014 )
1016 flow_token = res.flow_token
1017 assert flow_token
1019 # Get the original verification token.
1020 with session_scope() as session:
1021 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1023 old_email_token = flow.email_token
1024 assert flow.email == old_email
1025 assert old_email_token
1027 # Consume the initial email so we can specifically check the new email below.
1028 email_collector.pop_for_recipient(old_email, last=True)
1030 # Change the signup email.
1031 with auth_api_session() as (auth_api, metadata_interceptor):
1032 res = auth_api.SignupFlow(
1033 auth_pb2.SignupFlowReq(
1034 flow_token=flow_token,
1035 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1036 )
1037 )
1039 assert res.flow_token == flow_token
1040 assert res.need_verify_email
1042 # The signup should now have the new email and a new verification token.
1043 with session_scope() as session:
1044 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1046 assert flow.email == new_email
1047 assert flow.email != old_email
1049 new_email_token = flow.email_token
1050 assert new_email_token
1051 assert new_email_token != old_email_token
1052 assert flow.email_changed_count == 1
1054 # The old token should no longer be usable.
1055 with auth_api_session() as (auth_api, metadata_interceptor):
1056 with pytest.raises(grpc.RpcError) as e:
1057 auth_api.SignupFlow(
1058 auth_pb2.SignupFlowReq(
1059 email_token=old_email_token,
1060 )
1061 )
1063 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1065 # The new email should receive a verification link containing the new token.
1066 email = email_collector.pop_for_recipient(new_email, last=True)
1068 assert email.recipient == new_email
1069 assert new_email_token in email.plain
1070 assert new_email_token in email.html
1071 assert old_email_token not in email.plain
1072 assert old_email_token not in email.html
1075@pytest.mark.parametrize("invalid_email", ["bad email", "a@b", "a@b.", "@ab.cd", "a@b.c"])
1076def test_signup_change_new_invalid_email(db, invalid_email):
1077 old_email = f"{random_hex(12)}@couchers.org.invalid"
1079 # Create a signup with a valid email first.
1080 with auth_api_session() as (auth_api, metadata_interceptor):
1081 res = auth_api.SignupFlow(
1082 auth_pb2.SignupFlowReq(
1083 basic=auth_pb2.SignupBasic(
1084 name="frodo",
1085 email=old_email,
1086 )
1087 )
1088 )
1090 flow_token = res.flow_token
1091 assert flow_token
1093 # Try changing to an invalid email.
1094 with auth_api_session() as (auth_api, metadata_interceptor):
1095 with pytest.raises(grpc.RpcError) as e:
1096 auth_api.SignupFlow(
1097 auth_pb2.SignupFlowReq(
1098 flow_token=flow_token,
1099 change_email=auth_pb2.ChangeSignupEmail(
1100 new_email=invalid_email,
1101 ),
1102 )
1103 )
1105 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1106 assert e.value.details() == "Invalid email."
1108 # Make sure the original email wasn't changed.
1109 with session_scope() as session:
1110 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1112 assert flow.email == old_email
1115def test_signup_change_email_after_email_verified(db):
1116 old_email = f"{random_hex(12)}@couchers.org.invalid"
1117 new_email = f"{random_hex(12)}@couchers.org.invalid"
1119 # Start a signup.
1120 with auth_api_session() as (auth_api, metadata_interceptor):
1121 res = auth_api.SignupFlow(
1122 auth_pb2.SignupFlowReq(
1123 basic=auth_pb2.SignupBasic(
1124 name="testing",
1125 email=old_email,
1126 )
1127 )
1128 )
1130 flow_token = res.flow_token
1131 assert flow_token
1133 # Get the verification token.
1134 with session_scope() as session:
1135 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1137 email_token = flow.email_token
1138 assert email_token
1139 assert not flow.email_verified
1141 # Verify the original email.
1142 with auth_api_session() as (auth_api, metadata_interceptor):
1143 res = auth_api.SignupFlow(
1144 auth_pb2.SignupFlowReq(
1145 email_token=email_token,
1146 )
1147 )
1149 # The email should now be verified.
1150 with session_scope() as session:
1151 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1153 assert flow.email_verified
1155 # Changing the email is no longer allowed.
1156 with auth_api_session() as (auth_api, metadata_interceptor):
1157 with pytest.raises(grpc.RpcError) as e:
1158 auth_api.SignupFlow(
1159 auth_pb2.SignupFlowReq(
1160 flow_token=flow_token,
1161 change_email=auth_pb2.ChangeSignupEmail(
1162 new_email=new_email,
1163 ),
1164 )
1165 )
1166 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1167 assert e.value.details() == "You have already verified your email."
1169 # Make sure the signup email wasn't changed.
1170 with session_scope() as session:
1171 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1173 assert flow.email == old_email
1174 assert flow.email_verified
1177_INVALID_SIGNUP_REQUEST = (
1178 "Invalid signup request, you cannot recover your signup and continue signing up simultaneously."
1179)
1181# a sample value for every field that mutates a signup flow
1182_SIGNUP_FLOW_FIELDS: dict[str, Any] = {
1183 "basic": auth_pb2.SignupBasic(name="testing", email="other@couchers.org.invalid"),
1184 "account": auth_pb2.SignupAccount(username="frodo"),
1185 "feedback": auth_pb2.ContributorForm(),
1186 "motivations": auth_pb2.SignupMotivations(motivations=["surfing"]),
1187 "accept_community_guidelines": wrappers_pb2.BoolValue(value=True),
1188 "change_email": auth_pb2.ChangeSignupEmail(new_email="other@couchers.org.invalid"),
1189 "resend_verification_email": True,
1190}
1191_SIGNUP_STEP_FIELDS = ["basic", "account", "feedback", "motivations", "accept_community_guidelines"]
1192_RECOVERY_STEP_FIELDS = ["change_email", "resend_verification_email"]
1195def _start_signup_flow(email: str) -> str:
1196 with auth_api_session() as (auth_api, metadata_interceptor):
1197 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="testing", email=email)))
1198 assert res.flow_token
1199 return cast(str, res.flow_token)
1202def test_signup_change_email_same_email(db, email_collector: EmailCollector):
1203 """Changing to the email the flow already has is rejected, but the existing link is resent."""
1204 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1205 flow_token = _start_signup_flow(testing_email)
1207 with session_scope() as session:
1208 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1210 original_email_token = flow.email_token
1211 assert original_email_token
1213 # Consume the initial email so we can specifically check the one sent below.
1214 email_collector.pop_for_recipient(testing_email, last=True)
1216 with auth_api_session() as (auth_api, metadata_interceptor):
1217 with pytest.raises(grpc.RpcError) as e:
1218 auth_api.SignupFlow(
1219 auth_pb2.SignupFlowReq(
1220 flow_token=flow_token,
1221 change_email=auth_pb2.ChangeSignupEmail(new_email=testing_email),
1222 )
1223 )
1224 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1225 assert e.value.details() == "Please check your email for a link to continue signing up."
1227 # The existing verification link is resent.
1228 email = email_collector.pop_for_recipient(testing_email, last=True)
1229 assert email.recipient == testing_email
1230 assert original_email_token in email.plain
1231 assert original_email_token in email.html
1233 with session_scope() as session:
1234 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1236 assert flow.email == testing_email
1237 assert flow.email_token == original_email_token
1238 assert not flow.email_verified
1239 assert flow.email_changed_count == 0
1242def test_signup_change_email_to_existing_user_email(db):
1243 user, _ = generate_user()
1245 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1246 flow_token = _start_signup_flow(testing_email)
1248 with auth_api_session() as (auth_api, metadata_interceptor):
1249 with pytest.raises(grpc.RpcError) as e:
1250 auth_api.SignupFlow(
1251 auth_pb2.SignupFlowReq(
1252 flow_token=flow_token,
1253 change_email=auth_pb2.ChangeSignupEmail(new_email=user.email),
1254 )
1255 )
1256 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1257 assert e.value.details() == "That email address is already associated with an account. Please log in instead!"
1259 with session_scope() as session:
1260 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1262 assert flow.email == testing_email
1263 assert flow.email_changed_count == 0
1266@pytest.mark.parametrize("columns", [["banned_at"], ["banned_at", "deleted_at"]])
1267def test_signup_change_email_to_banned_user_email(db, columns):
1268 user, _ = generate_user()
1270 with session_scope() as session:
1271 session.execute(update(User).where(User.id == user.id).values(**{column: func.now() for column in columns}))
1273 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1274 flow_token = _start_signup_flow(testing_email)
1276 with auth_api_session() as (auth_api, metadata_interceptor):
1277 with pytest.raises(grpc.RpcError) as e:
1278 auth_api.SignupFlow(
1279 auth_pb2.SignupFlowReq(
1280 flow_token=flow_token,
1281 change_email=auth_pb2.ChangeSignupEmail(new_email=user.email),
1282 )
1283 )
1284 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1285 assert e.value.details() == "You cannot sign up with that email address."
1287 with session_scope() as session:
1288 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1290 assert flow.email == testing_email
1291 assert flow.email_changed_count == 0
1294def test_signup_change_email_to_other_flow_email(db, email_collector: EmailCollector):
1295 """Changing to an email that another signup already uses nudges that other signup instead."""
1296 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1297 other_email = f"{random_hex(12)}@couchers.org.invalid"
1299 flow_token = _start_signup_flow(testing_email)
1300 other_flow_token = _start_signup_flow(other_email)
1302 with session_scope() as session:
1303 other_flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == other_flow_token)).scalar_one()
1305 other_email_token = other_flow.email_token
1306 assert other_email_token
1308 email_collector.pop_for_recipient(other_email, last=True)
1310 with auth_api_session() as (auth_api, metadata_interceptor):
1311 with pytest.raises(grpc.RpcError) as e:
1312 auth_api.SignupFlow(
1313 auth_pb2.SignupFlowReq(
1314 flow_token=flow_token,
1315 change_email=auth_pb2.ChangeSignupEmail(new_email=other_email),
1316 )
1317 )
1318 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1319 assert e.value.details() == "Please check your email for a link to continue signing up."
1321 # The other signup gets its own link resent, this one is untouched.
1322 email = email_collector.pop_for_recipient(other_email, last=True)
1323 assert other_email_token in email.plain
1324 assert other_email_token in email.html
1326 with session_scope() as session:
1327 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1329 assert flow.email == testing_email
1330 assert flow.email_changed_count == 0
1333def test_signup_change_email_without_flow_token(db):
1334 with auth_api_session() as (auth_api, metadata_interceptor):
1335 with pytest.raises(grpc.RpcError) as e:
1336 auth_api.SignupFlow(
1337 auth_pb2.SignupFlowReq(
1338 change_email=auth_pb2.ChangeSignupEmail(new_email=f"{random_hex(12)}@couchers.org.invalid"),
1339 )
1340 )
1341 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1342 assert e.value.details() == "Account details needed to sign up."
1345def test_signup_change_email_and_resend_verification_email(db, email_collector: EmailCollector):
1346 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1347 new_email = f"{random_hex(12)}@couchers.org.invalid"
1348 flow_token = _start_signup_flow(testing_email)
1350 email_collector.pop_for_recipient(testing_email, last=True)
1352 with auth_api_session() as (auth_api, metadata_interceptor):
1353 with pytest.raises(grpc.RpcError) as e:
1354 auth_api.SignupFlow(
1355 auth_pb2.SignupFlowReq(
1356 flow_token=flow_token,
1357 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1358 resend_verification_email=True,
1359 )
1360 )
1361 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1362 assert e.value.details() == _INVALID_SIGNUP_REQUEST
1364 assert email_collector.count() == 0
1366 with session_scope() as session:
1367 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1369 assert flow.email == testing_email
1370 assert flow.email_changed_count == 0
1373@pytest.mark.parametrize("field", list(_SIGNUP_FLOW_FIELDS))
1374def test_signup_email_token_with_other_fields(db, field):
1375 """The email token makes the rest of the request be ignored, so sending both is rejected."""
1376 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1377 flow_token = _start_signup_flow(testing_email)
1379 with session_scope() as session:
1380 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1382 email_token = flow.email_token
1383 assert email_token
1385 with auth_api_session() as (auth_api, metadata_interceptor):
1386 with pytest.raises(grpc.RpcError) as e:
1387 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token, **{field: _SIGNUP_FLOW_FIELDS[field]}))
1388 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1389 assert e.value.details() == _INVALID_SIGNUP_REQUEST
1391 # the token wasn't consumed
1392 with session_scope() as session:
1393 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1395 assert flow.email == testing_email
1396 assert flow.email_token == email_token
1397 assert not flow.email_verified
1400@pytest.mark.parametrize("recovery_field", _RECOVERY_STEP_FIELDS)
1401@pytest.mark.parametrize("signup_field", _SIGNUP_STEP_FIELDS)
1402def test_signup_flow_signup_step_with_recovery_step(db, signup_field, recovery_field):
1403 """Continuing a signup and recovering it in the same request is rejected."""
1404 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1405 flow_token = _start_signup_flow(testing_email)
1407 with auth_api_session() as (auth_api, metadata_interceptor):
1408 with pytest.raises(grpc.RpcError) as e:
1409 auth_api.SignupFlow(
1410 auth_pb2.SignupFlowReq(
1411 flow_token=flow_token,
1412 **{
1413 signup_field: _SIGNUP_FLOW_FIELDS[signup_field],
1414 recovery_field: _SIGNUP_FLOW_FIELDS[recovery_field],
1415 },
1416 )
1417 )
1418 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1419 assert e.value.details() == _INVALID_SIGNUP_REQUEST
1421 with session_scope() as session:
1422 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1424 assert flow.email == testing_email
1425 assert flow.email_changed_count == 0
1426 assert not flow.account_is_filled
1429def test_signup_change_email_repeatedly(db):
1430 with patch.multiple("couchers.servicers.auth", signup_email_changes_counter=DEFAULT) as counters:
1431 flow_token = _start_signup_flow(f"{random_hex(12)}@couchers.org.invalid")
1433 for _ in range(3):
1434 new_email = f"{random_hex(12)}@couchers.org.invalid"
1435 with auth_api_session() as (auth_api, metadata_interceptor):
1436 res = auth_api.SignupFlow(
1437 auth_pb2.SignupFlowReq(
1438 flow_token=flow_token,
1439 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1440 )
1441 )
1442 assert res.flow_token == flow_token
1443 assert res.need_verify_email
1445 assert counters["signup_email_changes_counter"].inc.call_count == 3
1447 with session_scope() as session:
1448 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1450 assert flow.email == new_email
1451 assert flow.email_changed_count == 3
1454def test_signup_change_email_then_complete(db, email_collector: EmailCollector):
1455 old_email = f"{random_hex(12)}@couchers.org.invalid"
1456 new_email = f"{random_hex(12)}@couchers.org.invalid"
1458 with auth_api_session() as (auth_api, metadata_interceptor):
1459 res = auth_api.SignupFlow(
1460 auth_pb2.SignupFlowReq(
1461 basic=auth_pb2.SignupBasic(name="testing", email=old_email),
1462 account=auth_pb2.SignupAccount(
1463 username="frodo",
1464 password="a very insecure password",
1465 birthdate="1970-01-01",
1466 gender="Bot",
1467 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1468 city="New York City",
1469 lat=40.7331,
1470 lng=-73.9778,
1471 radius=500,
1472 accept_tos=True,
1473 ),
1474 feedback=auth_pb2.ContributorForm(),
1475 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1476 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1477 )
1478 )
1480 flow_token = res.flow_token
1481 assert flow_token
1482 assert res.need_verify_email
1484 email_collector.pop_for_recipient(old_email, last=True)
1486 with auth_api_session() as (auth_api, metadata_interceptor):
1487 res = auth_api.SignupFlow(
1488 auth_pb2.SignupFlowReq(
1489 flow_token=flow_token,
1490 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1491 )
1492 )
1494 # the flow is otherwise complete, so it must not finish until the new email is verified
1495 assert res.flow_token == flow_token
1496 assert res.need_verify_email
1497 assert not res.HasField("auth_res")
1499 with session_scope() as session:
1500 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1502 email_token = flow.email_token
1503 assert email_token
1505 email = email_collector.pop_for_recipient(new_email, last=True)
1506 assert email_token in email.plain
1507 assert email_token in email.html
1509 with auth_api_session() as (auth_api, metadata_interceptor):
1510 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1512 assert not res.flow_token
1513 assert res.HasField("auth_res")
1515 with session_scope() as session:
1516 user = session.execute(select(User).where(User.username == "frodo")).scalar_one()
1518 assert user.email == new_email
1521def test_successful_authenticate(db):
1522 user, _ = generate_user(hashed_password=hash_password("password"))
1524 # Authenticate with username
1525 with auth_api_session() as (auth_api, metadata_interceptor):
1526 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.username, password="password"))
1527 assert not reply.jailed
1529 # Authenticate with email
1530 with auth_api_session() as (auth_api, metadata_interceptor):
1531 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.email, password="password"))
1532 assert not reply.jailed
1535def test_unsuccessful_authenticate(db):
1536 user, _ = generate_user(hashed_password=hash_password("password"))
1538 # Invalid password
1539 with auth_api_session() as (auth_api, metadata_interceptor):
1540 with pytest.raises(grpc.RpcError) as e:
1541 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.username, password="incorrectpassword"))
1542 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1543 assert e.value.details() == "Wrong username/email or password."
1545 # Invalid username
1546 with auth_api_session() as (auth_api, metadata_interceptor):
1547 with pytest.raises(grpc.RpcError) as e:
1548 reply = auth_api.Authenticate(auth_pb2.AuthReq(user="notarealusername", password="password"))
1549 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1550 assert e.value.details() == "An account with that username or email was not found."
1552 # Invalid email
1553 with auth_api_session() as (auth_api, metadata_interceptor):
1554 with pytest.raises(grpc.RpcError) as e:
1555 reply = auth_api.Authenticate(
1556 auth_pb2.AuthReq(user=f"{random_hex(12)}@couchers.org.invalid", password="password")
1557 )
1558 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1559 assert e.value.details() == "An account with that username or email was not found."
1561 # Invalid id
1562 with auth_api_session() as (auth_api, metadata_interceptor):
1563 with pytest.raises(grpc.RpcError) as e:
1564 reply = auth_api.Authenticate(auth_pb2.AuthReq(user="-1", password="password"))
1565 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1566 assert e.value.details() == "An account with that username or email was not found."
1569def test_complete_signup(db):
1570 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1571 with auth_api_session() as (auth_api, metadata_interceptor):
1572 reply = auth_api.SignupFlow(
1573 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="Tester", email=testing_email))
1574 )
1576 flow_token = reply.flow_token
1578 with auth_api_session() as (auth_api, metadata_interceptor):
1579 # Invalid username
1580 with pytest.raises(grpc.RpcError) as e:
1581 auth_api.SignupFlow(
1582 auth_pb2.SignupFlowReq(
1583 flow_token=flow_token,
1584 account=auth_pb2.SignupAccount(
1585 username=" ",
1586 password="a very insecure password",
1587 city="Minas Tirith",
1588 birthdate="1980-12-31",
1589 gender="Robot",
1590 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1591 lat=1,
1592 lng=1,
1593 radius=100,
1594 accept_tos=True,
1595 ),
1596 )
1597 )
1598 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1599 assert e.value.details() == "Invalid username."
1601 with auth_api_session() as (auth_api, metadata_interceptor):
1602 # Invalid name
1603 with pytest.raises(grpc.RpcError) as e:
1604 auth_api.SignupFlow(
1605 auth_pb2.SignupFlowReq(
1606 basic=auth_pb2.SignupBasic(name=" ", email=f"{random_hex(12)}@couchers.org.invalid")
1607 )
1608 )
1609 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1610 assert e.value.details() == "Name not supported."
1612 with auth_api_session() as (auth_api, metadata_interceptor):
1613 # Hosting status required
1614 with pytest.raises(grpc.RpcError) as e:
1615 auth_api.SignupFlow(
1616 auth_pb2.SignupFlowReq(
1617 flow_token=flow_token,
1618 account=auth_pb2.SignupAccount(
1619 username="frodo",
1620 password="a very insecure password",
1621 city="Minas Tirith",
1622 birthdate="1980-12-31",
1623 gender="Robot",
1624 hosting_status=None,
1625 lat=1,
1626 lng=1,
1627 radius=100,
1628 accept_tos=True,
1629 ),
1630 )
1631 )
1632 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1633 assert e.value.details() == "Hosting status is required."
1635 user, _ = generate_user()
1636 with auth_api_session() as (auth_api, metadata_interceptor):
1637 # Username unavailable
1638 with pytest.raises(grpc.RpcError) as e:
1639 auth_api.SignupFlow(
1640 auth_pb2.SignupFlowReq(
1641 flow_token=flow_token,
1642 account=auth_pb2.SignupAccount(
1643 username=user.username,
1644 password="a very insecure password",
1645 city="Minas Tirith",
1646 birthdate="1980-12-31",
1647 gender="Robot",
1648 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1649 lat=1,
1650 lng=1,
1651 radius=100,
1652 accept_tos=True,
1653 ),
1654 )
1655 )
1656 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1657 assert e.value.details() == "Sorry, that username isn't available."
1659 with auth_api_session() as (auth_api, metadata_interceptor):
1660 # Invalid coordinate
1661 with pytest.raises(grpc.RpcError) as e:
1662 auth_api.SignupFlow(
1663 auth_pb2.SignupFlowReq(
1664 flow_token=flow_token,
1665 account=auth_pb2.SignupAccount(
1666 username="frodo",
1667 password="a very insecure password",
1668 city="Minas Tirith",
1669 birthdate="1980-12-31",
1670 gender="Robot",
1671 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1672 lat=0,
1673 lng=0,
1674 radius=100,
1675 accept_tos=True,
1676 ),
1677 )
1678 )
1679 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1680 assert e.value.details() == "Invalid coordinate."
1683def test_signup_token_regression(db):
1684 # Repro steps:
1685 # 1. Start a signup
1686 # 2. Confirm the email
1687 # 3. Start a new signup with the same email
1688 # Expected: send a link to the email to continue signing up.
1689 # Actual: `AttributeError: 'SignupFlow' object has no attribute 'token'`
1691 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1693 # 1. Start a signup
1694 with auth_api_session() as (auth_api, metadata_interceptor):
1695 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)))
1696 flow_token = res.flow_token
1697 assert flow_token
1699 # 2. Confirm the email
1700 with session_scope() as session:
1701 email_token = session.execute(
1702 select(SignupFlow.email_token).where(SignupFlow.flow_token == flow_token)
1703 ).scalar_one()
1705 with auth_api_session() as (auth_api, metadata_interceptor):
1706 auth_api.SignupFlow(
1707 auth_pb2.SignupFlowReq(
1708 flow_token=flow_token,
1709 email_token=email_token,
1710 )
1711 )
1713 # 3. Start a new signup with the same email
1714 with auth_api_session() as (auth_api, metadata_interceptor):
1715 with pytest.raises(grpc.RpcError) as e:
1716 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)))
1717 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1718 assert e.value.details() == "Please check your email for a link to continue signing up."
1721@pytest.mark.parametrize("opt_out", [True, False])
1722def test_opt_out_of_newsletter(db, opt_out):
1723 with auth_api_session() as (auth_api, metadata_interceptor):
1724 res = auth_api.SignupFlow(
1725 auth_pb2.SignupFlowReq(
1726 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
1727 account=auth_pb2.SignupAccount(
1728 username="frodo",
1729 password="a very insecure password",
1730 birthdate="1970-01-01",
1731 gender="Bot",
1732 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1733 city="New York City",
1734 lat=40.7331,
1735 lng=-73.9778,
1736 radius=500,
1737 accept_tos=True,
1738 opt_out_of_newsletter=opt_out,
1739 ),
1740 feedback=auth_pb2.ContributorForm(),
1741 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1742 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1743 )
1744 )
1746 with session_scope() as session:
1747 email_token = session.execute(
1748 select(SignupFlow.email_token).where(SignupFlow.flow_token == res.flow_token)
1749 ).scalar_one()
1751 with auth_api_session() as (auth_api, metadata_interceptor):
1752 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1754 user_id = res.auth_res.user_id
1756 with session_scope() as session:
1757 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
1758 assert not user.in_sync_with_newsletter
1759 assert user.opt_out_of_newsletter == opt_out
1762def test_GetAuthState(db):
1763 user, token = generate_user()
1764 jailed_user, jailed_token = generate_user(accepted_tos=0)
1766 with auth_api_session() as (auth_api, metadata_interceptor):
1767 res = auth_api.GetAuthState(empty_pb2.Empty())
1768 assert not res.logged_in
1769 assert not res.HasField("auth_res")
1771 with auth_api_session() as (auth_api, metadata_interceptor):
1772 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),))
1773 assert res.logged_in
1774 assert res.HasField("auth_res")
1775 assert res.auth_res.user_id == user.id
1776 assert not res.auth_res.jailed
1778 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),))
1780 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),))
1781 assert not res.logged_in
1782 assert not res.HasField("auth_res")
1784 with auth_api_session() as (auth_api, metadata_interceptor):
1785 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={jailed_token}"),))
1786 assert res.logged_in
1787 assert res.HasField("auth_res")
1788 assert res.auth_res.user_id == jailed_user.id
1789 assert res.auth_res.jailed
1792def test_signup_no_feedback_regression(db):
1793 """
1794 When we first remove the feedback form, the backned was saying it's not needed but was not completing the signup,
1795 this regression test checks that.
1796 """
1797 with auth_api_session() as (auth_api, metadata_interceptor):
1798 res = auth_api.SignupFlow(
1799 auth_pb2.SignupFlowReq(
1800 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
1801 account=auth_pb2.SignupAccount(
1802 username="frodo",
1803 password="a very insecure password",
1804 birthdate="1970-01-01",
1805 gender="Bot",
1806 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1807 city="New York City",
1808 lat=40.7331,
1809 lng=-73.9778,
1810 radius=500,
1811 accept_tos=True,
1812 ),
1813 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1814 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1815 )
1816 )
1818 flow_token = res.flow_token
1820 assert res.flow_token
1821 assert not res.HasField("auth_res")
1822 assert not res.need_basic
1823 assert not res.need_account
1824 assert not res.need_feedback
1825 assert not res.need_motivations
1826 assert res.need_verify_email
1828 # read out the signup token directly from the database for now
1829 with session_scope() as session:
1830 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1831 assert flow.email_sent
1832 assert not flow.email_verified
1833 email_token = flow.email_token
1835 with auth_api_session() as (auth_api, metadata_interceptor):
1836 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1838 assert not res.flow_token
1839 assert res.HasField("auth_res")
1840 assert res.auth_res.user_id
1841 assert not res.auth_res.jailed
1842 assert not res.need_basic
1843 assert not res.need_account
1844 assert not res.need_feedback
1845 assert not res.need_motivations
1846 assert not res.need_verify_email
1848 # make sure we got the right token in a cookie
1849 with session_scope() as session:
1850 token = session.execute(
1851 select(UserSession.token).join(User, UserSession.user_id == User.id).where(User.username == "frodo")
1852 ).scalar_one()
1853 sesh, uid = get_session_cookie_tokens(metadata_interceptor)
1854 assert sesh == token
1857def test_banned_username(db):
1858 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1859 with auth_api_session() as (auth_api, metadata_interceptor):
1860 reply = auth_api.SignupFlow(
1861 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="Tester", email=testing_email))
1862 )
1864 flow_token = reply.flow_token
1866 with auth_api_session() as (auth_api, metadata_interceptor):
1867 # Banned username
1868 with pytest.raises(grpc.RpcError) as e:
1869 auth_api.SignupFlow(
1870 auth_pb2.SignupFlowReq(
1871 flow_token=flow_token,
1872 account=auth_pb2.SignupAccount(
1873 username="thecouchersadminaccount",
1874 password="a very insecure password",
1875 city="Minas Tirith",
1876 birthdate="1980-12-31",
1877 gender="Robot",
1878 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1879 lat=1,
1880 lng=1,
1881 radius=100,
1882 accept_tos=True,
1883 ),
1884 )
1885 )
1886 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1887 assert e.value.details() == "Sorry, that username isn't available."
1890# tests for ConfirmChangeEmail within test_account.py tests for test_ChangeEmail_*
1893def test_GetInviteCodeInfo(db):
1894 user, token = generate_user(complete_profile=True)
1896 with account_session(token) as account:
1897 code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code
1899 with auth_api_session() as (auth, _):
1900 res = auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code=code))
1901 assert res.name == user.name
1902 assert res.username == user.username
1903 # Avatar URL should be a thumbnail URL with a hashed filename
1904 assert "/img/thumbnail/" in res.avatar_url
1905 assert res.avatar_url.endswith(".jpg")
1906 # Verify the hashed filename looks correct (64 char hex hash)
1907 assert len(res.avatar_url.split("/")[-1].replace(".jpg", "")) == 64
1908 assert res.url == urls.invite_code_link(code=code)
1911def test_GetInviteCodeInfo_no_avatar(db):
1912 user, token = generate_user(complete_profile=False)
1914 with account_session(token) as account:
1915 code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code
1917 with auth_api_session() as (auth, _):
1918 res = auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code=code))
1919 assert res.name == user.name
1920 assert res.username == user.username
1921 assert res.avatar_url == ""
1922 assert res.url == urls.invite_code_link(code=code)
1925def test_GetInviteCodeInfo_not_found(db):
1926 generate_user()
1928 with auth_api_session() as (auth, _):
1929 with pytest.raises(grpc.RpcError) as e:
1930 auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code="BADCODE"))
1931 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1932 assert e.value.details() == "Invite code not found."
1935def test_SignupFlow_invite_code(db):
1936 user, token = generate_user()
1938 with account_session(token) as account:
1939 invite_code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code
1941 with auth_api_session() as (auth_api, _):
1942 # Signup basic step with invite code
1943 res = auth_api.SignupFlow(
1944 auth_pb2.SignupFlowReq(
1945 basic=auth_pb2.SignupBasic(
1946 name="Test User",
1947 email="inviteuser@example.com",
1948 invite_code=invite_code,
1949 )
1950 )
1951 )
1952 flow_token = res.flow_token
1953 assert flow_token
1955 # Confirm email
1956 with session_scope() as session:
1957 email_token = session.execute(
1958 select(SignupFlow.email_token).where(SignupFlow.flow_token == flow_token)
1959 ).scalar_one()
1961 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1963 # Signup account step
1964 auth_api.SignupFlow(
1965 auth_pb2.SignupFlowReq(
1966 flow_token=flow_token,
1967 account=auth_pb2.SignupAccount(
1968 username="invited_user",
1969 password="secure password",
1970 birthdate="1990-01-01",
1971 gender="Other",
1972 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1973 city="Example City",
1974 lat=1,
1975 lng=5,
1976 radius=100,
1977 accept_tos=True,
1978 ),
1979 feedback=auth_pb2.ContributorForm(),
1980 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1981 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1982 )
1983 )
1985 # Check that invite_code_id is stored in the final User object
1986 with session_scope() as session:
1987 invite_code_id = session.execute(
1988 select(User.invite_code_id).where(User.username == "invited_user")
1989 ).scalar_one()
1990 assert invite_code_id == invite_code
1993def test_signup_with_motivations(db):
1994 """
1995 Test signup flow with the new motivations step (heard_about_couchers and signup_motivations)
1996 """
1997 with auth_api_session() as (auth_api, metadata_interceptor):
1998 res = auth_api.SignupFlow(
1999 auth_pb2.SignupFlowReq(
2000 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
2001 account=auth_pb2.SignupAccount(
2002 username="intentuser",
2003 password="a very insecure password",
2004 birthdate="1970-01-01",
2005 gender="Bot",
2006 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
2007 city="New York City",
2008 lat=40.7331,
2009 lng=-73.9778,
2010 radius=500,
2011 accept_tos=True,
2012 ),
2013 motivations=auth_pb2.SignupMotivations(
2014 heard_about_couchers="friend",
2015 motivations=["hosting", "surfing", "events"],
2016 ),
2017 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
2018 )
2019 )
2021 flow_token = res.flow_token
2022 assert flow_token
2023 assert not res.HasField("auth_res")
2024 assert res.need_verify_email
2026 # Verify the motivations are stored in the SignupFlow
2027 with session_scope() as session:
2028 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2029 assert flow.heard_about_couchers == "friend"
2030 assert set(flow.signup_motivations) == {"hosting", "surfing", "events"}
2031 email_token = flow.email_token
2033 # Complete signup by verifying email
2034 with auth_api_session() as (auth_api, metadata_interceptor):
2035 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
2037 assert res.HasField("auth_res")
2038 user_id = res.auth_res.user_id
2040 # Verify the motivations are transferred to the User object
2041 with session_scope() as session:
2042 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
2043 assert user.heard_about_couchers == "friend"
2044 assert user.signup_motivations is not None
2045 assert set(user.signup_motivations) == {"hosting", "surfing", "events"}
2048def test_signup_motivations_incremental(db):
2049 """
2050 Test that motivations can be submitted incrementally as a separate step
2051 """
2052 with auth_api_session() as (auth_api, metadata_interceptor):
2053 # First, basic signup
2054 res = auth_api.SignupFlow(
2055 auth_pb2.SignupFlowReq(
2056 basic=auth_pb2.SignupBasic(name="testing", email="email2@couchers.org.invalid"),
2057 )
2058 )
2060 flow_token = res.flow_token
2061 assert flow_token
2062 assert res.need_account
2063 assert res.need_motivations # New field
2065 # Submit motivations separately
2066 with auth_api_session() as (auth_api, metadata_interceptor):
2067 res = auth_api.SignupFlow(
2068 auth_pb2.SignupFlowReq(
2069 flow_token=flow_token,
2070 motivations=auth_pb2.SignupMotivations(
2071 heard_about_couchers="social_media",
2072 motivations=["surfing"],
2073 ),
2074 )
2075 )
2077 assert res.flow_token == flow_token
2078 assert not res.need_motivations # Should be filled now
2079 assert res.need_account # Still need account
2081 # Verify motivations are stored
2082 with session_scope() as session:
2083 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2084 assert flow.heard_about_couchers == "social_media"
2085 assert flow.signup_motivations == ["surfing"]
2088def test_signup_motivations_cannot_be_refilled(db):
2089 """
2090 Test that motivations cannot be submitted twice
2091 """
2092 with auth_api_session() as (auth_api, metadata_interceptor):
2093 res = auth_api.SignupFlow(
2094 auth_pb2.SignupFlowReq(
2095 basic=auth_pb2.SignupBasic(name="testing", email="email3@couchers.org.invalid"),
2096 motivations=auth_pb2.SignupMotivations(
2097 heard_about_couchers="friend",
2098 motivations=["hosting"],
2099 ),
2100 )
2101 )
2103 flow_token = res.flow_token
2105 # Try to submit motivations again - should fail
2106 with auth_api_session() as (auth_api, metadata_interceptor):
2107 with pytest.raises(grpc.RpcError) as e:
2108 auth_api.SignupFlow(
2109 auth_pb2.SignupFlowReq(
2110 flow_token=flow_token,
2111 motivations=auth_pb2.SignupMotivations(
2112 heard_about_couchers="different_source",
2113 motivations=["surfing"],
2114 ),
2115 )
2116 )
2117 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
2118 assert e.value.details() == "You've already told us about why you are signing up."
2121def test_signup_motivations_required(db):
2122 """
2123 Test that signup cannot complete without providing motivations
2124 """
2125 with auth_api_session() as (auth_api, metadata_interceptor):
2126 res = auth_api.SignupFlow(
2127 auth_pb2.SignupFlowReq(
2128 basic=auth_pb2.SignupBasic(name="testing", email="email4@couchers.org.invalid"),
2129 account=auth_pb2.SignupAccount(
2130 username="nointents",
2131 password="a very insecure password",
2132 birthdate="1970-01-01",
2133 gender="Bot",
2134 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
2135 city="New York City",
2136 lat=40.7331,
2137 lng=-73.9778,
2138 radius=500,
2139 accept_tos=True,
2140 ),
2141 # No motivations provided
2142 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
2143 )
2144 )
2146 flow_token = res.flow_token
2147 assert not res.HasField("auth_res")
2148 assert res.need_motivations # Intents still required
2150 with session_scope() as session:
2151 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2152 email_token = flow.email_token
2154 # Verify email - signup still not complete without motivations
2155 with auth_api_session() as (auth_api, metadata_interceptor):
2156 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
2158 assert not res.HasField("auth_res")
2159 assert res.need_motivations
2161 # Now submit motivations
2162 with auth_api_session() as (auth_api, metadata_interceptor):
2163 res = auth_api.SignupFlow(
2164 auth_pb2.SignupFlowReq(
2165 flow_token=flow_token,
2166 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
2167 )
2168 )
2170 assert res.HasField("auth_res")
2171 user_id = res.auth_res.user_id
2173 with session_scope() as session:
2174 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
2175 assert user.signup_motivations == ["surfing"]
2178def test_signup_motivations_all_options(db):
2179 """
2180 Test all the different motivation options
2181 """
2182 with auth_api_session() as (auth_api, metadata_interceptor):
2183 res = auth_api.SignupFlow(
2184 auth_pb2.SignupFlowReq(
2185 basic=auth_pb2.SignupBasic(name="testing", email="email5@couchers.org.invalid"),
2186 motivations=auth_pb2.SignupMotivations(
2187 heard_about_couchers="other",
2188 motivations=["hosting", "surfing", "events"],
2189 ),
2190 )
2191 )
2193 flow_token = res.flow_token
2195 with session_scope() as session:
2196 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2197 assert flow.heard_about_couchers == "other"
2198 assert set(flow.signup_motivations) == {"hosting", "surfing", "events"}
2201def test_signup_motivations_empty_motivations_list(db):
2202 """
2203 Test that providing heard_about but empty motivations list is valid
2204 """
2205 with auth_api_session() as (auth_api, metadata_interceptor):
2206 res = auth_api.SignupFlow(
2207 auth_pb2.SignupFlowReq(
2208 basic=auth_pb2.SignupBasic(name="testing", email="email6@couchers.org.invalid"),
2209 motivations=auth_pb2.SignupMotivations(
2210 heard_about_couchers="former_cs_member",
2211 motivations=[], # No specific motivations selected
2212 ),
2213 )
2214 )
2216 flow_token = res.flow_token
2218 with session_scope() as session:
2219 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2220 assert flow.heard_about_couchers == "former_cs_member"
2221 assert flow.signup_motivations == []
2224def test_create_session_does_not_reselect_the_user(db):
2225 """
2226 The commit in create_session expires the user, so anything read off it afterwards re-selects the whole row.
2228 This is on the path of every login, so it was the most executed statement in the query log.
2229 """
2230 user, _ = generate_user()
2232 statements = []
2234 def record(conn, cursor, statement, parameters, context, executemany):
2235 statements.append(statement)
2237 engine = _get_base_engine()
2238 with session_scope() as session:
2239 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one()
2240 context = cast(CouchersContext, _MockCouchersContext())
2241 event.listen(engine, "before_cursor_execute", record)
2242 try:
2243 create_session(context, session, db_user, False)
2244 finally:
2245 event.remove(engine, "before_cursor_execute", record)
2247 assert not [statement for statement in statements if "FROM users" in statement]