Coverage for app/backend/src/tests/test_auth.py: 100%
1017 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import http.cookies
2from typing import Any, cast
3from unittest.mock import DEFAULT, patch
5import grpc
6import pytest
7from google.protobuf import empty_pb2, wrappers_pb2
8from sqlalchemy import event, select, update
9from sqlalchemy.sql import delete, func
11from couchers import urls
12from couchers.context import CouchersContext
13from couchers.crypto import hash_password, random_hex
14from couchers.db import _get_base_engine, session_scope
15from couchers.models import (
16 ContributeOption,
17 ContributorForm,
18 LoginToken,
19 ModerationObjectType,
20 ModerationState,
21 NonvisibleUserAccess,
22 NonvisibleUserAccessType,
23 NonvisibleUserState,
24 PasswordResetToken,
25 SignupFlow,
26 User,
27 UserSession,
28)
29from couchers.proto import account_pb2, api_pb2, auth_pb2
30from couchers.servicers.auth import create_session
31from couchers.utils import now
32from tests.fixtures.db import generate_user
33from tests.fixtures.misc import EmailCollector, PushCollector
34from tests.fixtures.sessions import (
35 MetadataKeeperInterceptor,
36 _MockCouchersContext,
37 account_session,
38 api_session,
39 auth_api_session,
40 real_api_session,
41)
44@pytest.fixture(autouse=True)
45def _(fast_passwords):
46 pass
49def get_session_cookie_tokens(metadata_interceptor: MetadataKeeperInterceptor) -> tuple[str, str]:
50 set_cookies = [val for key, val in metadata_interceptor.latest_header_raw if key == "set-cookie"]
51 sesh = http.cookies.SimpleCookie([v for v in set_cookies if "sesh" in v][0])["couchers-sesh"].value
52 uid = http.cookies.SimpleCookie([v for v in set_cookies if "user-id" in v][0])["couchers-user-id"].value
53 return sesh, uid
56def test_UsernameValid(db):
57 with auth_api_session() as (auth_api, metadata_interceptor):
58 assert auth_api.UsernameValid(auth_pb2.UsernameValidReq(username="test")).valid
60 with auth_api_session() as (auth_api, metadata_interceptor):
61 assert not auth_api.UsernameValid(auth_pb2.UsernameValidReq(username="")).valid
64def test_signup_incremental(db):
65 with auth_api_session() as (auth_api, metadata_interceptor):
66 res = auth_api.SignupFlow(
67 auth_pb2.SignupFlowReq(
68 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
69 )
70 )
72 flow_token = res.flow_token
73 assert res.flow_token
74 assert not res.HasField("auth_res")
75 assert res.email == "email@couchers.org.invalid"
76 assert not res.need_basic
77 assert res.need_account
78 assert not res.need_feedback
79 assert res.need_verify_email
80 assert res.need_accept_community_guidelines
81 assert res.need_motivations
83 # read out the signup token directly from the database for now
84 with session_scope() as session:
85 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
86 assert flow.email_sent
87 assert not flow.email_verified
88 email_token = flow.email_token
90 with auth_api_session() as (auth_api, metadata_interceptor):
91 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(flow_token=flow_token))
93 assert res.flow_token == flow_token
94 assert not res.HasField("auth_res")
95 assert res.email == "email@couchers.org.invalid"
96 assert not res.need_basic
97 assert res.need_account
98 assert not res.need_feedback
99 assert res.need_verify_email
100 assert res.need_accept_community_guidelines
101 assert res.need_motivations
103 # Add feedback
104 with auth_api_session() as (auth_api, metadata_interceptor):
105 res = auth_api.SignupFlow(
106 auth_pb2.SignupFlowReq(
107 flow_token=flow_token,
108 feedback=auth_pb2.ContributorForm(
109 ideas="I'm a robot, incapable of original ideation",
110 features="I love all your features",
111 experience="I haven't done couch surfing before",
112 contribute=auth_pb2.CONTRIBUTE_OPTION_YES,
113 contribute_ways=["serving", "backend"],
114 expertise="I'd love to be your server: I can compute very fast, but only simple opcodes",
115 ),
116 )
117 )
119 assert res.flow_token == flow_token
120 assert not res.HasField("auth_res")
121 assert res.email == "email@couchers.org.invalid"
122 assert not res.need_basic
123 assert res.need_account
124 assert not res.need_feedback
125 assert res.need_verify_email
126 assert res.need_accept_community_guidelines
127 assert res.need_motivations
129 # Agree to community guidelines
130 with auth_api_session() as (auth_api, metadata_interceptor):
131 res = auth_api.SignupFlow(
132 auth_pb2.SignupFlowReq(
133 flow_token=flow_token,
134 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
135 )
136 )
138 assert res.flow_token == flow_token
139 assert not res.HasField("auth_res")
140 assert res.email == "email@couchers.org.invalid"
141 assert not res.need_basic
142 assert res.need_account
143 assert not res.need_feedback
144 assert res.need_verify_email
145 assert not res.need_accept_community_guidelines
146 assert res.need_motivations
148 # Submit motivations
149 with auth_api_session() as (auth_api, metadata_interceptor):
150 res = auth_api.SignupFlow(
151 auth_pb2.SignupFlowReq(
152 flow_token=flow_token,
153 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
154 )
155 )
157 assert res.flow_token == flow_token
158 assert not res.HasField("auth_res")
159 assert res.email == "email@couchers.org.invalid"
160 assert not res.need_basic
161 assert res.need_account
162 assert not res.need_feedback
163 assert res.need_verify_email
164 assert not res.need_accept_community_guidelines
165 assert not res.need_motivations
167 # Verify email
168 with auth_api_session() as (auth_api, metadata_interceptor):
169 res = auth_api.SignupFlow(
170 auth_pb2.SignupFlowReq(
171 flow_token=flow_token,
172 email_token=email_token,
173 )
174 )
176 assert res.flow_token == flow_token
177 assert not res.HasField("auth_res")
178 assert res.email == "email@couchers.org.invalid"
179 assert not res.need_basic
180 assert res.need_account
181 assert not res.need_feedback
182 assert not res.need_verify_email
183 assert not res.need_accept_community_guidelines
184 assert not res.need_motivations
186 # Finally finish off account info
187 with auth_api_session() as (auth_api, metadata_interceptor):
188 res = auth_api.SignupFlow(
189 auth_pb2.SignupFlowReq(
190 flow_token=flow_token,
191 account=auth_pb2.SignupAccount(
192 username="frodo",
193 password="a very insecure password",
194 birthdate="1970-01-01",
195 gender="Bot",
196 hosting_status=api_pb2.HOSTING_STATUS_MAYBE,
197 city="New York City",
198 lat=40.7331,
199 lng=-73.9778,
200 radius=500,
201 accept_tos=True,
202 ),
203 )
204 )
206 assert not res.flow_token
207 assert res.HasField("auth_res")
208 assert not res.email
209 assert res.auth_res.user_id
210 assert not res.auth_res.jailed
211 assert not res.need_basic
212 assert not res.need_account
213 assert not res.need_feedback
214 assert not res.need_verify_email
215 assert not res.need_accept_community_guidelines
216 assert not res.need_motivations
218 user_id = res.auth_res.user_id
220 sess_token, uid = get_session_cookie_tokens(metadata_interceptor)
221 assert uid == str(user_id)
223 with api_session(sess_token) as api:
224 res = api.GetUser(api_pb2.GetUserReq(user=str(user_id)))
226 assert res.username == "frodo"
227 assert res.gender == "Bot"
228 assert res.hosting_status == api_pb2.HOSTING_STATUS_MAYBE
229 assert res.city == "New York City"
230 assert res.lat == 40.7331
231 assert res.lng == -73.9778
232 assert res.radius == 500
234 with session_scope() as session:
235 form = session.execute(select(ContributorForm)).scalar_one()
237 assert form.ideas == "I'm a robot, incapable of original ideation"
238 assert form.features == "I love all your features"
239 assert form.experience == "I haven't done couch surfing before"
240 assert form.contribute == ContributeOption.yes
241 assert form.contribute_ways == ["serving", "backend"]
242 assert form.expertise == "I'd love to be your server: I can compute very fast, but only simple opcodes"
245def test_signup_funnel_counters(db):
246 """Each per-step signup funnel counter should fire exactly once across an incremental signup."""
247 with patch.multiple(
248 "couchers.servicers.auth",
249 signup_initiations_counter=DEFAULT,
250 signup_account_filled_counter=DEFAULT,
251 signup_email_verified_counter=DEFAULT,
252 signup_guidelines_accepted_counter=DEFAULT,
253 signup_motivations_filled_counter=DEFAULT,
254 signup_completions_counter=DEFAULT,
255 ) as counters:
256 with auth_api_session() as (auth_api, metadata_interceptor):
257 res = auth_api.SignupFlow(
258 auth_pb2.SignupFlowReq(
259 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
260 )
261 )
262 flow_token = res.flow_token
263 counters["signup_initiations_counter"].inc.assert_called_once()
265 with session_scope() as session:
266 email_token = (
267 session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one().email_token
268 )
270 with auth_api_session() as (auth_api, metadata_interceptor):
271 auth_api.SignupFlow(
272 auth_pb2.SignupFlowReq(
273 flow_token=flow_token,
274 account=auth_pb2.SignupAccount(
275 username="frodo",
276 password="a very insecure password",
277 birthdate="1970-01-01",
278 gender="Bot",
279 hosting_status=api_pb2.HOSTING_STATUS_MAYBE,
280 city="New York City",
281 lat=40.7331,
282 lng=-73.9778,
283 radius=500,
284 accept_tos=True,
285 ),
286 )
287 )
288 counters["signup_account_filled_counter"].inc.assert_called_once()
290 # accept the guidelines twice; the counter must still only fire once
291 with auth_api_session() as (auth_api, metadata_interceptor):
292 auth_api.SignupFlow(
293 auth_pb2.SignupFlowReq(
294 flow_token=flow_token,
295 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
296 )
297 )
298 with auth_api_session() as (auth_api, metadata_interceptor):
299 auth_api.SignupFlow(
300 auth_pb2.SignupFlowReq(
301 flow_token=flow_token,
302 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
303 )
304 )
305 counters["signup_guidelines_accepted_counter"].inc.assert_called_once()
307 with auth_api_session() as (auth_api, metadata_interceptor):
308 auth_api.SignupFlow(
309 auth_pb2.SignupFlowReq(
310 flow_token=flow_token,
311 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
312 )
313 )
314 counters["signup_motivations_filled_counter"].inc.assert_called_once()
316 with auth_api_session() as (auth_api, metadata_interceptor):
317 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(flow_token=flow_token, email_token=email_token))
318 counters["signup_email_verified_counter"].inc.assert_called_once()
320 assert res.HasField("auth_res")
321 counters["signup_completions_counter"].labels.assert_called_once_with("Bot")
324def _quick_signup() -> int:
325 with auth_api_session() as (auth_api, metadata_interceptor):
326 res = auth_api.SignupFlow(
327 auth_pb2.SignupFlowReq(
328 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
329 account=auth_pb2.SignupAccount(
330 username="frodo",
331 password="a very insecure password",
332 birthdate="1970-01-01",
333 gender="Bot",
334 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
335 city="New York City",
336 lat=40.7331,
337 lng=-73.9778,
338 radius=500,
339 accept_tos=True,
340 ),
341 feedback=auth_pb2.ContributorForm(),
342 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
343 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
344 )
345 )
347 flow_token = res.flow_token
349 assert res.flow_token
350 assert not res.HasField("auth_res")
351 assert not res.need_basic
352 assert not res.need_account
353 assert not res.need_feedback
354 assert not res.need_motivations
355 assert res.need_verify_email
357 # read out the signup token directly from the database for now
358 with session_scope() as session:
359 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
360 assert flow.email_sent
361 assert not flow.email_verified
362 email_token = flow.email_token
364 with auth_api_session() as (auth_api, metadata_interceptor):
365 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
367 assert not res.flow_token
368 assert res.HasField("auth_res")
369 assert res.auth_res.user_id
370 assert not res.auth_res.jailed
371 assert not res.need_basic
372 assert not res.need_account
373 assert not res.need_feedback
374 assert not res.need_motivations
375 assert not res.need_verify_email
377 # make sure we got the right token in a cookie
378 with session_scope() as session:
379 token = session.execute(
380 select(UserSession.token).join(User, UserSession.user_id == User.id).where(User.username == "frodo")
381 ).scalar_one()
382 sesh, uid = get_session_cookie_tokens(metadata_interceptor)
383 assert sesh == token
385 return cast(int, res.auth_res.user_id)
388def test_signup(db):
389 _quick_signup()
392def test_signup_creates_user_moderation_state(db):
393 user_id = _quick_signup()
395 with session_scope() as session:
396 state = session.execute(
397 select(ModerationState)
398 .where(ModerationState.object_type == ModerationObjectType.user)
399 .where(ModerationState.object_id == user_id)
400 ).scalar_one()
401 assert state.visibility is None
402 assert session.execute(select(User.moderation_state_id).where(User.id == user_id)).scalar_one() == state.id
405def test_basic_login(db):
406 # Create our test user using signup
407 _quick_signup()
409 with auth_api_session() as (auth_api, metadata_interceptor):
410 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
412 reply_token, _ = get_session_cookie_tokens(metadata_interceptor)
414 with session_scope() as session:
415 token = session.execute(
416 select(UserSession.token)
417 .join(User, UserSession.user_id == User.id)
418 .where(User.username == "frodo")
419 .where(UserSession.token == reply_token)
420 .where(UserSession.is_valid)
421 ).scalar_one_or_none()
422 assert token
424 # log out
425 with auth_api_session() as (auth_api, metadata_interceptor):
426 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={reply_token}"),))
429def test_login_part_signed_up_verified_email(db):
430 """
431 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.
432 """
433 with auth_api_session() as (auth_api, metadata_interceptor):
434 res = auth_api.SignupFlow(
435 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"))
436 )
438 flow_token = res.flow_token
439 assert res.need_verify_email
441 # verify the email
442 with session_scope() as session:
443 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
444 flow_token = flow.flow_token
445 email_token = flow.email_token
446 with auth_api_session() as (auth_api, metadata_interceptor):
447 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
449 with EmailCollector() as email_collector:
450 with auth_api_session() as (auth_api, metadata_interceptor):
451 with pytest.raises(grpc.RpcError) as err:
452 auth_api.Authenticate(auth_pb2.AuthReq(user="email@couchers.org.invalid", password="wrong pwd"))
453 assert err.value.details() == "Please check your email for a link to continue signing up."
455 email = email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
456 assert email.recipient == "email@couchers.org.invalid"
457 assert flow_token in email.plain
458 assert flow_token in email.html
461def test_login_part_signed_up_not_verified_email(db):
462 with auth_api_session() as (auth_api, metadata_interceptor):
463 res = auth_api.SignupFlow(
464 auth_pb2.SignupFlowReq(
465 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
466 account=auth_pb2.SignupAccount(
467 username="frodo",
468 password="a very insecure password",
469 birthdate="1999-01-01",
470 gender="Bot",
471 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
472 city="New York City",
473 lat=40.7331,
474 lng=-73.9778,
475 radius=500,
476 accept_tos=True,
477 ),
478 )
479 )
481 flow_token = res.flow_token
482 assert res.need_verify_email
484 with EmailCollector() as email_collector:
485 with auth_api_session() as (auth_api, metadata_interceptor):
486 with pytest.raises(grpc.RpcError) as err:
487 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="wrong pwd"))
488 assert err.value.details() == "Please check your email for a link to continue signing up."
490 with session_scope() as session:
491 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
492 email_token = flow.email_token
494 email = email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
495 assert email.recipient == "email@couchers.org.invalid"
496 assert email_token
497 assert email_token in email.plain
498 assert email_token in email.html
501def test_banned_user(db):
502 user_id = _quick_signup()
504 with session_scope() as session:
505 session.execute(select(User)).scalar_one().banned_at = now()
507 with auth_api_session() as (auth_api, metadata_interceptor):
508 with pytest.raises(grpc.RpcError) as e:
509 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
510 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
511 assert e.value.details() == "Your account is suspended."
513 with session_scope() as session:
514 access = session.execute(select(NonvisibleUserAccess)).scalar_one()
515 assert access.access_type == NonvisibleUserAccessType.login_attempt
516 assert access.target_state == NonvisibleUserState.banned
517 assert access.target_user_id == user_id
518 assert access.actor_user_id == user_id
521def test_shadowed_user_login_logged(db):
522 user_id = _quick_signup()
524 with session_scope() as session:
525 session.execute(select(User)).scalar_one().shadowed_at = now()
527 with auth_api_session() as (auth_api, metadata_interceptor):
528 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
530 with session_scope() as session:
531 access = session.execute(select(NonvisibleUserAccess)).scalar_one()
532 assert access.access_type == NonvisibleUserAccessType.login_attempt
533 assert access.target_state == NonvisibleUserState.shadowed
534 assert access.target_user_id == user_id
535 assert access.actor_user_id == user_id
538def test_deleted_user(db):
539 user_id = _quick_signup()
541 with session_scope() as session:
542 session.execute(update(User).where(User.id == user_id).values(deleted_at=func.now()))
544 with auth_api_session() as (auth_api, metadata_interceptor):
545 with pytest.raises(grpc.RpcError) as e:
546 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
547 assert e.value.code() == grpc.StatusCode.NOT_FOUND
548 assert e.value.details() == "An account with that username or email was not found."
551def test_invalid_token(db):
552 user1, token1 = generate_user()
553 user2, token2 = generate_user()
555 wrong_token = random_hex(32)
557 with real_api_session(wrong_token) as api, pytest.raises(grpc.RpcError) as e:
558 res = api.GetUser(api_pb2.GetUserReq(user=user2.username))
560 assert e.value.code() == grpc.StatusCode.UNAUTHENTICATED
561 assert e.value.details() == "Unauthorized"
564def test_password_reset_v2(db, email_collector: EmailCollector, push_collector: PushCollector):
565 user, token = generate_user(hashed_password=hash_password("mypassword"))
567 with auth_api_session() as (auth_api, metadata_interceptor):
568 res = auth_api.ResetPassword(auth_pb2.ResetPasswordReq(user=user.username))
570 with session_scope() as session:
571 password_reset_token = session.execute(select(PasswordResetToken.token)).scalar_one()
573 email = email_collector.pop_for_recipient(user.email, last=True)
574 assert email.recipient == user.email
575 assert "reset" in email.subject.lower()
576 assert password_reset_token in email.plain
577 assert password_reset_token in email.html
578 unique_string = "You asked for your password to be reset on Couchers.org."
579 assert unique_string in email.plain
580 assert unique_string in email.html
581 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in email.plain
582 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in email.html
583 assert "support@couchers.org" in email.plain
584 assert "support@couchers.org" in email.html
586 push = push_collector.pop_for_user(user.id, last=True)
587 assert push.content.title == "Password reset requested"
588 assert push.content.body == "Use the link we sent by email to complete it."
590 # make sure bad password are caught
591 with auth_api_session() as (auth_api, metadata_interceptor):
592 with pytest.raises(grpc.RpcError) as err:
593 auth_api.CompletePasswordResetV2(
594 auth_pb2.CompletePasswordResetV2Req(password_reset_token=password_reset_token, new_password="password")
595 )
596 assert err.value.code() == grpc.StatusCode.INVALID_ARGUMENT
597 assert err.value.details() == "The password is insecure. Please use one that is not easily guessable."
599 # make sure we can set a good password
600 with auth_api_session() as (auth_api, metadata_interceptor):
601 pwd = random_hex()
602 auth_api.CompletePasswordResetV2(
603 auth_pb2.CompletePasswordResetV2Req(password_reset_token=password_reset_token, new_password=pwd)
604 )
606 push = push_collector.pop_for_user(user.id, last=True)
607 assert push.content.title == "Password reset"
608 assert push.content.body == "Your password was successfully reset."
610 session_token, _ = get_session_cookie_tokens(metadata_interceptor)
612 with session_scope() as session:
613 other_session_token = session.execute(
614 select(UserSession.token)
615 .join(User, UserSession.user_id == User.id)
616 .where(User.username == user.username)
617 .where(UserSession.token == session_token)
618 .where(UserSession.is_valid)
619 ).scalar_one_or_none()
620 assert other_session_token
622 # make sure we can't set a password again
623 with auth_api_session() as (auth_api, metadata_interceptor):
624 with pytest.raises(grpc.RpcError) as err:
625 auth_api.CompletePasswordResetV2(
626 auth_pb2.CompletePasswordResetV2Req(
627 password_reset_token=password_reset_token, new_password=random_hex()
628 )
629 )
630 assert err.value.code() == grpc.StatusCode.NOT_FOUND
631 assert err.value.details() == "Invalid token."
633 with session_scope() as session:
634 user = session.execute(select(User)).scalar_one()
635 assert user.hashed_password == hash_password(pwd)
638def test_password_reset_no_such_user(db):
639 user, token = generate_user()
641 with auth_api_session() as (auth_api, metadata_interceptor):
642 res = auth_api.ResetPassword(
643 auth_pb2.ResetPasswordReq(
644 user="nonexistentuser",
645 )
646 )
648 with session_scope() as session:
649 assert session.execute(select(PasswordResetToken)).scalar_one_or_none() is None
652def test_password_reset_invalid_token_v2(db):
653 password = random_hex()
654 user, token = generate_user(hashed_password=hash_password(password))
656 with auth_api_session() as (auth_api, metadata_interceptor):
657 res = auth_api.ResetPassword(
658 auth_pb2.ResetPasswordReq(
659 user=user.username,
660 )
661 )
663 with auth_api_session() as (auth_api, metadata_interceptor), pytest.raises(grpc.RpcError) as e:
664 res = auth_api.CompletePasswordResetV2(auth_pb2.CompletePasswordResetV2Req(password_reset_token="wrongtoken"))
665 assert e.value.code() == grpc.StatusCode.NOT_FOUND
666 assert e.value.details() == "Invalid token."
668 with session_scope() as session:
669 user = session.execute(select(User)).scalar_one()
670 assert user.hashed_password == hash_password(password)
673def test_logout_invalid_token(db):
674 # Create our test user using signup
675 _quick_signup()
677 with auth_api_session() as (auth_api, metadata_interceptor):
678 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password"))
680 reply_token, _ = get_session_cookie_tokens(metadata_interceptor)
682 # delete all login tokens
683 with session_scope() as session:
684 session.execute(delete(LoginToken))
686 # log out with non-existent token should still return a valid result
687 with auth_api_session() as (auth_api, metadata_interceptor):
688 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={reply_token}"),))
690 reply_token, _ = get_session_cookie_tokens(metadata_interceptor)
691 # make sure we set an empty cookie
692 assert reply_token == ""
695def test_signup_without_password(db):
696 with auth_api_session() as (auth_api, metadata_interceptor):
697 with pytest.raises(grpc.RpcError) as e:
698 auth_api.SignupFlow(
699 auth_pb2.SignupFlowReq(
700 basic=auth_pb2.SignupBasic(name="Räksmörgås", email="a1@b.com"),
701 account=auth_pb2.SignupAccount(
702 username="frodo",
703 password="bad",
704 city="Minas Tirith",
705 birthdate="9999-12-31", # arbitrary future birthdate
706 gender="Robot",
707 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
708 lat=1,
709 lng=1,
710 radius=100,
711 accept_tos=True,
712 ),
713 feedback=auth_pb2.ContributorForm(),
714 )
715 )
716 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
717 assert e.value.details() == "The password must be 8 or more characters long."
720def test_signup_invalid_birthdate(db):
721 with auth_api_session() as (auth_api, metadata_interceptor):
722 with pytest.raises(grpc.RpcError) as e:
723 auth_api.SignupFlow(
724 auth_pb2.SignupFlowReq(
725 basic=auth_pb2.SignupBasic(name="Räksmörgås", email="a1@b.com"),
726 account=auth_pb2.SignupAccount(
727 username="frodo",
728 password="a very insecure password",
729 city="Minas Tirith",
730 birthdate="9999-12-31", # arbitrary future birthdate
731 gender="Robot",
732 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
733 lat=1,
734 lng=1,
735 radius=100,
736 accept_tos=True,
737 ),
738 feedback=auth_pb2.ContributorForm(),
739 )
740 )
741 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
742 assert e.value.details() == "You must be at least 18 years old to sign up."
744 res = auth_api.SignupFlow(
745 auth_pb2.SignupFlowReq(
746 basic=auth_pb2.SignupBasic(name="Christopher", email="a2@b.com"),
747 account=auth_pb2.SignupAccount(
748 username="ceelo",
749 password="a very insecure password",
750 city="New York City",
751 birthdate="2000-12-31", # arbitrary birthdate older than 18 years
752 gender="Helicopter",
753 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
754 lat=1,
755 lng=1,
756 radius=100,
757 accept_tos=True,
758 ),
759 feedback=auth_pb2.ContributorForm(),
760 )
761 )
763 assert res.flow_token
765 with pytest.raises(grpc.RpcError) as e:
766 auth_api.SignupFlow(
767 auth_pb2.SignupFlowReq(
768 basic=auth_pb2.SignupBasic(name="Franklin", email="a3@b.com"),
769 account=auth_pb2.SignupAccount(
770 username="franklin",
771 password="a very insecure password",
772 city="Los Santos",
773 birthdate="2010-04-09", # arbitrary birthdate < 18 yrs
774 gender="Male",
775 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
776 lat=1,
777 lng=1,
778 radius=100,
779 accept_tos=True,
780 ),
781 feedback=auth_pb2.ContributorForm(),
782 )
783 )
784 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
785 assert e.value.details() == "You must be at least 18 years old to sign up."
787 with session_scope() as session:
788 assert session.execute(select(func.count()).select_from(SignupFlow)).scalar_one() == 1
791def test_signup_invalid_email(db):
792 with auth_api_session() as (auth_api, metadata_interceptor):
793 with pytest.raises(grpc.RpcError) as e:
794 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a")))
795 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
796 assert e.value.details() == "Invalid email."
798 with auth_api_session() as (auth_api, metadata_interceptor):
799 with pytest.raises(grpc.RpcError) as e:
800 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b")))
801 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
802 assert e.value.details() == "Invalid email."
804 with auth_api_session() as (auth_api, metadata_interceptor):
805 with pytest.raises(grpc.RpcError) as e:
806 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b.")))
807 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
808 assert e.value.details() == "Invalid email."
810 with auth_api_session() as (auth_api, metadata_interceptor):
811 with pytest.raises(grpc.RpcError) as e:
812 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b.c")))
813 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
814 assert e.value.details() == "Invalid email."
817def test_signup_existing_email(db):
818 # Signed up user
819 user, _ = generate_user()
821 with auth_api_session() as (auth_api, metadata_interceptor):
822 with pytest.raises(grpc.RpcError) as e:
823 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=user.email)))
824 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
825 assert e.value.details() == "That email address is already associated with an account. Please log in instead!"
828def test_signup_banned_user_email(db):
829 user, _ = generate_user()
831 with session_scope() as session:
832 session.execute(update(User).where(User.id == user.id).values(banned_at=func.now()))
834 with auth_api_session() as (auth_api, _):
835 with pytest.raises(grpc.RpcError) as e:
836 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email)))
837 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
838 assert e.value.details() == "You cannot sign up with that email address."
841def test_signup_deleted_user_email(db):
842 user, _ = generate_user()
844 with session_scope() as session:
845 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now()))
847 with auth_api_session() as (auth_api, _):
848 with pytest.raises(grpc.RpcError) as e:
849 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email)))
850 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
851 assert e.value.details() == "You cannot sign up with that email address."
854def test_signup_continue_with_email(db):
855 testing_email = f"{random_hex(12)}@couchers.org.invalid"
856 with auth_api_session() as (auth_api, metadata_interceptor):
857 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)))
858 flow_token = res.flow_token
859 assert flow_token
861 # continue with same email, should just send another email to the user
862 with auth_api_session() as (auth_api, metadata_interceptor):
863 with pytest.raises(grpc.RpcError) as e:
864 res = auth_api.SignupFlow(
865 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email))
866 )
867 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
868 assert e.value.details() == "Please check your email for a link to continue signing up."
871def test_signup_resend_email(db, email_collector: EmailCollector):
872 with auth_api_session() as (auth_api, metadata_interceptor):
873 res = auth_api.SignupFlow(
874 auth_pb2.SignupFlowReq(
875 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
876 account=auth_pb2.SignupAccount(
877 username="frodo",
878 password="a very insecure password",
879 birthdate="1970-01-01",
880 gender="Bot",
881 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
882 city="New York City",
883 lat=40.7331,
884 lng=-73.9778,
885 radius=500,
886 accept_tos=True,
887 ),
888 feedback=auth_pb2.ContributorForm(),
889 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
890 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
891 )
892 )
894 email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
896 flow_token = res.flow_token
897 assert flow_token
899 with session_scope() as session:
900 flow = session.execute(select(SignupFlow)).scalar_one()
901 assert flow.flow_token == flow_token
902 assert flow.email_sent
903 assert not flow.email_verified
904 email_token = flow.email_token
906 # ask for a new signup email
907 with auth_api_session() as (auth_api, metadata_interceptor):
908 res = auth_api.SignupFlow(
909 auth_pb2.SignupFlowReq(
910 flow_token=flow_token,
911 resend_verification_email=True,
912 )
913 )
915 email = email_collector.pop_for_recipient("email@couchers.org.invalid", last=True)
916 assert email_token
917 assert email_token in email.plain
918 assert email_token in email.html
920 with session_scope() as session:
921 flow = session.execute(select(SignupFlow)).scalar_one()
922 assert not flow.email_verified
924 with auth_api_session() as (auth_api, metadata_interceptor):
925 res = auth_api.SignupFlow(
926 auth_pb2.SignupFlowReq(
927 email_token=email_token,
928 )
929 )
931 assert not res.flow_token
932 assert res.HasField("auth_res")
935def test_signup_change_email(db, email_collector: EmailCollector):
936 old_email = f"{random_hex(12)}@couchers.org.invalid"
937 new_email = f"{random_hex(12)}@couchers.org.invalid"
939 # Start a signup with the old email.
940 with auth_api_session() as (auth_api, metadata_interceptor):
941 res = auth_api.SignupFlow(
942 auth_pb2.SignupFlowReq(
943 basic=auth_pb2.SignupBasic(
944 name="testing",
945 email=old_email,
946 )
947 )
948 )
950 flow_token = res.flow_token
951 assert flow_token
953 # Get the original verification token.
954 with session_scope() as session:
955 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
957 old_email_token = flow.email_token
958 assert flow.email == old_email
959 assert old_email_token
961 # Consume the initial email so we can specifically check the new email below.
962 email_collector.pop_for_recipient(old_email, last=True)
964 # Change the signup email.
965 with auth_api_session() as (auth_api, metadata_interceptor):
966 res = auth_api.SignupFlow(
967 auth_pb2.SignupFlowReq(
968 flow_token=flow_token,
969 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
970 )
971 )
973 assert res.flow_token == flow_token
974 assert res.need_verify_email
976 # The signup should now have the new email and a new verification token.
977 with session_scope() as session:
978 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
980 assert flow.email == new_email
981 assert flow.email != old_email
983 new_email_token = flow.email_token
984 assert new_email_token
985 assert new_email_token != old_email_token
986 assert flow.email_changed_count == 1
988 # The old token should no longer be usable.
989 with auth_api_session() as (auth_api, metadata_interceptor):
990 with pytest.raises(grpc.RpcError) as e:
991 auth_api.SignupFlow(
992 auth_pb2.SignupFlowReq(
993 email_token=old_email_token,
994 )
995 )
997 assert e.value.code() == grpc.StatusCode.NOT_FOUND
999 # The new email should receive a verification link containing the new token.
1000 email = email_collector.pop_for_recipient(new_email, last=True)
1002 assert email.recipient == new_email
1003 assert new_email_token in email.plain
1004 assert new_email_token in email.html
1005 assert old_email_token not in email.plain
1006 assert old_email_token not in email.html
1009@pytest.mark.parametrize("invalid_email", ["bad email", "a@b", "a@b.", "@ab.cd", "a@b.c"])
1010def test_signup_change_new_invalid_email(db, invalid_email):
1011 old_email = f"{random_hex(12)}@couchers.org.invalid"
1013 # Create a signup with a valid email first.
1014 with auth_api_session() as (auth_api, metadata_interceptor):
1015 res = auth_api.SignupFlow(
1016 auth_pb2.SignupFlowReq(
1017 basic=auth_pb2.SignupBasic(
1018 name="frodo",
1019 email=old_email,
1020 )
1021 )
1022 )
1024 flow_token = res.flow_token
1025 assert flow_token
1027 # Try changing to an invalid email.
1028 with auth_api_session() as (auth_api, metadata_interceptor):
1029 with pytest.raises(grpc.RpcError) as e:
1030 auth_api.SignupFlow(
1031 auth_pb2.SignupFlowReq(
1032 flow_token=flow_token,
1033 change_email=auth_pb2.ChangeSignupEmail(
1034 new_email=invalid_email,
1035 ),
1036 )
1037 )
1039 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1040 assert e.value.details() == "Invalid email."
1042 # Make sure the original email wasn't changed.
1043 with session_scope() as session:
1044 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1046 assert flow.email == old_email
1049def test_signup_change_email_after_email_verified(db):
1050 old_email = f"{random_hex(12)}@couchers.org.invalid"
1051 new_email = f"{random_hex(12)}@couchers.org.invalid"
1053 # Start a signup.
1054 with auth_api_session() as (auth_api, metadata_interceptor):
1055 res = auth_api.SignupFlow(
1056 auth_pb2.SignupFlowReq(
1057 basic=auth_pb2.SignupBasic(
1058 name="testing",
1059 email=old_email,
1060 )
1061 )
1062 )
1064 flow_token = res.flow_token
1065 assert flow_token
1067 # Get the verification token.
1068 with session_scope() as session:
1069 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1071 email_token = flow.email_token
1072 assert email_token
1073 assert not flow.email_verified
1075 # Verify the original email.
1076 with auth_api_session() as (auth_api, metadata_interceptor):
1077 res = auth_api.SignupFlow(
1078 auth_pb2.SignupFlowReq(
1079 email_token=email_token,
1080 )
1081 )
1083 # The email should now be verified.
1084 with session_scope() as session:
1085 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1087 assert flow.email_verified
1089 # Changing the email is no longer allowed.
1090 with auth_api_session() as (auth_api, metadata_interceptor):
1091 with pytest.raises(grpc.RpcError) as e:
1092 auth_api.SignupFlow(
1093 auth_pb2.SignupFlowReq(
1094 flow_token=flow_token,
1095 change_email=auth_pb2.ChangeSignupEmail(
1096 new_email=new_email,
1097 ),
1098 )
1099 )
1100 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1101 assert e.value.details() == "You have already verified your email."
1103 # Make sure the signup email wasn't changed.
1104 with session_scope() as session:
1105 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1107 assert flow.email == old_email
1108 assert flow.email_verified
1111_INVALID_SIGNUP_REQUEST = (
1112 "Invalid signup request, you cannot recover your signup and continue signing up simultaneously."
1113)
1115# a sample value for every field that mutates a signup flow
1116_SIGNUP_FLOW_FIELDS: dict[str, Any] = {
1117 "basic": auth_pb2.SignupBasic(name="testing", email="other@couchers.org.invalid"),
1118 "account": auth_pb2.SignupAccount(username="frodo"),
1119 "feedback": auth_pb2.ContributorForm(),
1120 "motivations": auth_pb2.SignupMotivations(motivations=["surfing"]),
1121 "accept_community_guidelines": wrappers_pb2.BoolValue(value=True),
1122 "change_email": auth_pb2.ChangeSignupEmail(new_email="other@couchers.org.invalid"),
1123 "resend_verification_email": True,
1124}
1125_SIGNUP_STEP_FIELDS = ["basic", "account", "feedback", "motivations", "accept_community_guidelines"]
1126_RECOVERY_STEP_FIELDS = ["change_email", "resend_verification_email"]
1129def _start_signup_flow(email: str) -> str:
1130 with auth_api_session() as (auth_api, metadata_interceptor):
1131 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="testing", email=email)))
1132 assert res.flow_token
1133 return cast(str, res.flow_token)
1136def test_signup_change_email_same_email(db, email_collector: EmailCollector):
1137 """Changing to the email the flow already has is rejected, but the existing link is resent."""
1138 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1139 flow_token = _start_signup_flow(testing_email)
1141 with session_scope() as session:
1142 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1144 original_email_token = flow.email_token
1145 assert original_email_token
1147 # Consume the initial email so we can specifically check the one sent below.
1148 email_collector.pop_for_recipient(testing_email, last=True)
1150 with auth_api_session() as (auth_api, metadata_interceptor):
1151 with pytest.raises(grpc.RpcError) as e:
1152 auth_api.SignupFlow(
1153 auth_pb2.SignupFlowReq(
1154 flow_token=flow_token,
1155 change_email=auth_pb2.ChangeSignupEmail(new_email=testing_email),
1156 )
1157 )
1158 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1159 assert e.value.details() == "Please check your email for a link to continue signing up."
1161 # The existing verification link is resent.
1162 email = email_collector.pop_for_recipient(testing_email, last=True)
1163 assert email.recipient == testing_email
1164 assert original_email_token in email.plain
1165 assert original_email_token in email.html
1167 with session_scope() as session:
1168 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1170 assert flow.email == testing_email
1171 assert flow.email_token == original_email_token
1172 assert not flow.email_verified
1173 assert flow.email_changed_count == 0
1176def test_signup_change_email_to_existing_user_email(db):
1177 user, _ = generate_user()
1179 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1180 flow_token = _start_signup_flow(testing_email)
1182 with auth_api_session() as (auth_api, metadata_interceptor):
1183 with pytest.raises(grpc.RpcError) as e:
1184 auth_api.SignupFlow(
1185 auth_pb2.SignupFlowReq(
1186 flow_token=flow_token,
1187 change_email=auth_pb2.ChangeSignupEmail(new_email=user.email),
1188 )
1189 )
1190 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1191 assert e.value.details() == "That email address is already associated with an account. Please log in instead!"
1193 with session_scope() as session:
1194 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1196 assert flow.email == testing_email
1197 assert flow.email_changed_count == 0
1200@pytest.mark.parametrize("invisible_column", ["banned_at", "deleted_at"])
1201def test_signup_change_email_to_invisible_user_email(db, invisible_column):
1202 user, _ = generate_user()
1204 with session_scope() as session:
1205 session.execute(update(User).where(User.id == user.id).values(**{invisible_column: func.now()}))
1207 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1208 flow_token = _start_signup_flow(testing_email)
1210 with auth_api_session() as (auth_api, metadata_interceptor):
1211 with pytest.raises(grpc.RpcError) as e:
1212 auth_api.SignupFlow(
1213 auth_pb2.SignupFlowReq(
1214 flow_token=flow_token,
1215 change_email=auth_pb2.ChangeSignupEmail(new_email=user.email),
1216 )
1217 )
1218 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1219 assert e.value.details() == "You cannot sign up with that email address."
1221 with session_scope() as session:
1222 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1224 assert flow.email == testing_email
1225 assert flow.email_changed_count == 0
1228def test_signup_change_email_to_other_flow_email(db, email_collector: EmailCollector):
1229 """Changing to an email that another signup already uses nudges that other signup instead."""
1230 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1231 other_email = f"{random_hex(12)}@couchers.org.invalid"
1233 flow_token = _start_signup_flow(testing_email)
1234 other_flow_token = _start_signup_flow(other_email)
1236 with session_scope() as session:
1237 other_flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == other_flow_token)).scalar_one()
1239 other_email_token = other_flow.email_token
1240 assert other_email_token
1242 email_collector.pop_for_recipient(other_email, last=True)
1244 with auth_api_session() as (auth_api, metadata_interceptor):
1245 with pytest.raises(grpc.RpcError) as e:
1246 auth_api.SignupFlow(
1247 auth_pb2.SignupFlowReq(
1248 flow_token=flow_token,
1249 change_email=auth_pb2.ChangeSignupEmail(new_email=other_email),
1250 )
1251 )
1252 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1253 assert e.value.details() == "Please check your email for a link to continue signing up."
1255 # The other signup gets its own link resent, this one is untouched.
1256 email = email_collector.pop_for_recipient(other_email, last=True)
1257 assert other_email_token in email.plain
1258 assert other_email_token in email.html
1260 with session_scope() as session:
1261 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1263 assert flow.email == testing_email
1264 assert flow.email_changed_count == 0
1267def test_signup_change_email_without_flow_token(db):
1268 with auth_api_session() as (auth_api, metadata_interceptor):
1269 with pytest.raises(grpc.RpcError) as e:
1270 auth_api.SignupFlow(
1271 auth_pb2.SignupFlowReq(
1272 change_email=auth_pb2.ChangeSignupEmail(new_email=f"{random_hex(12)}@couchers.org.invalid"),
1273 )
1274 )
1275 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1276 assert e.value.details() == "Account details needed to sign up."
1279def test_signup_change_email_and_resend_verification_email(db, email_collector: EmailCollector):
1280 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1281 new_email = f"{random_hex(12)}@couchers.org.invalid"
1282 flow_token = _start_signup_flow(testing_email)
1284 email_collector.pop_for_recipient(testing_email, last=True)
1286 with auth_api_session() as (auth_api, metadata_interceptor):
1287 with pytest.raises(grpc.RpcError) as e:
1288 auth_api.SignupFlow(
1289 auth_pb2.SignupFlowReq(
1290 flow_token=flow_token,
1291 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1292 resend_verification_email=True,
1293 )
1294 )
1295 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1296 assert e.value.details() == _INVALID_SIGNUP_REQUEST
1298 assert email_collector.count() == 0
1300 with session_scope() as session:
1301 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1303 assert flow.email == testing_email
1304 assert flow.email_changed_count == 0
1307@pytest.mark.parametrize("field", list(_SIGNUP_FLOW_FIELDS))
1308def test_signup_email_token_with_other_fields(db, field):
1309 """The email token makes the rest of the request be ignored, so sending both is rejected."""
1310 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1311 flow_token = _start_signup_flow(testing_email)
1313 with session_scope() as session:
1314 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1316 email_token = flow.email_token
1317 assert email_token
1319 with auth_api_session() as (auth_api, metadata_interceptor):
1320 with pytest.raises(grpc.RpcError) as e:
1321 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token, **{field: _SIGNUP_FLOW_FIELDS[field]}))
1322 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1323 assert e.value.details() == _INVALID_SIGNUP_REQUEST
1325 # the token wasn't consumed
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_token == email_token
1331 assert not flow.email_verified
1334@pytest.mark.parametrize("recovery_field", _RECOVERY_STEP_FIELDS)
1335@pytest.mark.parametrize("signup_field", _SIGNUP_STEP_FIELDS)
1336def test_signup_flow_signup_step_with_recovery_step(db, signup_field, recovery_field):
1337 """Continuing a signup and recovering it in the same request is rejected."""
1338 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1339 flow_token = _start_signup_flow(testing_email)
1341 with auth_api_session() as (auth_api, metadata_interceptor):
1342 with pytest.raises(grpc.RpcError) as e:
1343 auth_api.SignupFlow(
1344 auth_pb2.SignupFlowReq(
1345 flow_token=flow_token,
1346 **{
1347 signup_field: _SIGNUP_FLOW_FIELDS[signup_field],
1348 recovery_field: _SIGNUP_FLOW_FIELDS[recovery_field],
1349 },
1350 )
1351 )
1352 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1353 assert e.value.details() == _INVALID_SIGNUP_REQUEST
1355 with session_scope() as session:
1356 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1358 assert flow.email == testing_email
1359 assert flow.email_changed_count == 0
1360 assert not flow.account_is_filled
1363def test_signup_change_email_repeatedly(db):
1364 with patch.multiple("couchers.servicers.auth", signup_email_changes_counter=DEFAULT) as counters:
1365 flow_token = _start_signup_flow(f"{random_hex(12)}@couchers.org.invalid")
1367 for _ in range(3):
1368 new_email = f"{random_hex(12)}@couchers.org.invalid"
1369 with auth_api_session() as (auth_api, metadata_interceptor):
1370 res = auth_api.SignupFlow(
1371 auth_pb2.SignupFlowReq(
1372 flow_token=flow_token,
1373 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1374 )
1375 )
1376 assert res.flow_token == flow_token
1377 assert res.need_verify_email
1379 assert counters["signup_email_changes_counter"].inc.call_count == 3
1381 with session_scope() as session:
1382 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1384 assert flow.email == new_email
1385 assert flow.email_changed_count == 3
1388def test_signup_change_email_then_complete(db, email_collector: EmailCollector):
1389 old_email = f"{random_hex(12)}@couchers.org.invalid"
1390 new_email = f"{random_hex(12)}@couchers.org.invalid"
1392 with auth_api_session() as (auth_api, metadata_interceptor):
1393 res = auth_api.SignupFlow(
1394 auth_pb2.SignupFlowReq(
1395 basic=auth_pb2.SignupBasic(name="testing", email=old_email),
1396 account=auth_pb2.SignupAccount(
1397 username="frodo",
1398 password="a very insecure password",
1399 birthdate="1970-01-01",
1400 gender="Bot",
1401 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1402 city="New York City",
1403 lat=40.7331,
1404 lng=-73.9778,
1405 radius=500,
1406 accept_tos=True,
1407 ),
1408 feedback=auth_pb2.ContributorForm(),
1409 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1410 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1411 )
1412 )
1414 flow_token = res.flow_token
1415 assert flow_token
1416 assert res.need_verify_email
1418 email_collector.pop_for_recipient(old_email, last=True)
1420 with auth_api_session() as (auth_api, metadata_interceptor):
1421 res = auth_api.SignupFlow(
1422 auth_pb2.SignupFlowReq(
1423 flow_token=flow_token,
1424 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email),
1425 )
1426 )
1428 # the flow is otherwise complete, so it must not finish until the new email is verified
1429 assert res.flow_token == flow_token
1430 assert res.need_verify_email
1431 assert not res.HasField("auth_res")
1433 with session_scope() as session:
1434 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1436 email_token = flow.email_token
1437 assert email_token
1439 email = email_collector.pop_for_recipient(new_email, last=True)
1440 assert email_token in email.plain
1441 assert email_token in email.html
1443 with auth_api_session() as (auth_api, metadata_interceptor):
1444 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1446 assert not res.flow_token
1447 assert res.HasField("auth_res")
1449 with session_scope() as session:
1450 user = session.execute(select(User).where(User.username == "frodo")).scalar_one()
1452 assert user.email == new_email
1455def test_successful_authenticate(db):
1456 user, _ = generate_user(hashed_password=hash_password("password"))
1458 # Authenticate with username
1459 with auth_api_session() as (auth_api, metadata_interceptor):
1460 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.username, password="password"))
1461 assert not reply.jailed
1463 # Authenticate with email
1464 with auth_api_session() as (auth_api, metadata_interceptor):
1465 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.email, password="password"))
1466 assert not reply.jailed
1469def test_unsuccessful_authenticate(db):
1470 user, _ = generate_user(hashed_password=hash_password("password"))
1472 # Invalid password
1473 with auth_api_session() as (auth_api, metadata_interceptor):
1474 with pytest.raises(grpc.RpcError) as e:
1475 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.username, password="incorrectpassword"))
1476 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1477 assert e.value.details() == "Wrong username/email or password."
1479 # Invalid username
1480 with auth_api_session() as (auth_api, metadata_interceptor):
1481 with pytest.raises(grpc.RpcError) as e:
1482 reply = auth_api.Authenticate(auth_pb2.AuthReq(user="notarealusername", password="password"))
1483 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1484 assert e.value.details() == "An account with that username or email was not found."
1486 # Invalid email
1487 with auth_api_session() as (auth_api, metadata_interceptor):
1488 with pytest.raises(grpc.RpcError) as e:
1489 reply = auth_api.Authenticate(
1490 auth_pb2.AuthReq(user=f"{random_hex(12)}@couchers.org.invalid", password="password")
1491 )
1492 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1493 assert e.value.details() == "An account with that username or email was not found."
1495 # Invalid id
1496 with auth_api_session() as (auth_api, metadata_interceptor):
1497 with pytest.raises(grpc.RpcError) as e:
1498 reply = auth_api.Authenticate(auth_pb2.AuthReq(user="-1", password="password"))
1499 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1500 assert e.value.details() == "An account with that username or email was not found."
1503def test_complete_signup(db):
1504 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1505 with auth_api_session() as (auth_api, metadata_interceptor):
1506 reply = auth_api.SignupFlow(
1507 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="Tester", email=testing_email))
1508 )
1510 flow_token = reply.flow_token
1512 with auth_api_session() as (auth_api, metadata_interceptor):
1513 # Invalid username
1514 with pytest.raises(grpc.RpcError) as e:
1515 auth_api.SignupFlow(
1516 auth_pb2.SignupFlowReq(
1517 flow_token=flow_token,
1518 account=auth_pb2.SignupAccount(
1519 username=" ",
1520 password="a very insecure password",
1521 city="Minas Tirith",
1522 birthdate="1980-12-31",
1523 gender="Robot",
1524 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1525 lat=1,
1526 lng=1,
1527 radius=100,
1528 accept_tos=True,
1529 ),
1530 )
1531 )
1532 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1533 assert e.value.details() == "Invalid username."
1535 with auth_api_session() as (auth_api, metadata_interceptor):
1536 # Invalid name
1537 with pytest.raises(grpc.RpcError) as e:
1538 auth_api.SignupFlow(
1539 auth_pb2.SignupFlowReq(
1540 basic=auth_pb2.SignupBasic(name=" ", email=f"{random_hex(12)}@couchers.org.invalid")
1541 )
1542 )
1543 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1544 assert e.value.details() == "Name not supported."
1546 with auth_api_session() as (auth_api, metadata_interceptor):
1547 # Hosting status required
1548 with pytest.raises(grpc.RpcError) as e:
1549 auth_api.SignupFlow(
1550 auth_pb2.SignupFlowReq(
1551 flow_token=flow_token,
1552 account=auth_pb2.SignupAccount(
1553 username="frodo",
1554 password="a very insecure password",
1555 city="Minas Tirith",
1556 birthdate="1980-12-31",
1557 gender="Robot",
1558 hosting_status=None,
1559 lat=1,
1560 lng=1,
1561 radius=100,
1562 accept_tos=True,
1563 ),
1564 )
1565 )
1566 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1567 assert e.value.details() == "Hosting status is required."
1569 user, _ = generate_user()
1570 with auth_api_session() as (auth_api, metadata_interceptor):
1571 # Username unavailable
1572 with pytest.raises(grpc.RpcError) as e:
1573 auth_api.SignupFlow(
1574 auth_pb2.SignupFlowReq(
1575 flow_token=flow_token,
1576 account=auth_pb2.SignupAccount(
1577 username=user.username,
1578 password="a very insecure password",
1579 city="Minas Tirith",
1580 birthdate="1980-12-31",
1581 gender="Robot",
1582 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1583 lat=1,
1584 lng=1,
1585 radius=100,
1586 accept_tos=True,
1587 ),
1588 )
1589 )
1590 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1591 assert e.value.details() == "Sorry, that username isn't available."
1593 with auth_api_session() as (auth_api, metadata_interceptor):
1594 # Invalid coordinate
1595 with pytest.raises(grpc.RpcError) as e:
1596 auth_api.SignupFlow(
1597 auth_pb2.SignupFlowReq(
1598 flow_token=flow_token,
1599 account=auth_pb2.SignupAccount(
1600 username="frodo",
1601 password="a very insecure password",
1602 city="Minas Tirith",
1603 birthdate="1980-12-31",
1604 gender="Robot",
1605 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1606 lat=0,
1607 lng=0,
1608 radius=100,
1609 accept_tos=True,
1610 ),
1611 )
1612 )
1613 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1614 assert e.value.details() == "Invalid coordinate."
1617def test_signup_token_regression(db):
1618 # Repro steps:
1619 # 1. Start a signup
1620 # 2. Confirm the email
1621 # 3. Start a new signup with the same email
1622 # Expected: send a link to the email to continue signing up.
1623 # Actual: `AttributeError: 'SignupFlow' object has no attribute 'token'`
1625 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1627 # 1. Start a signup
1628 with auth_api_session() as (auth_api, metadata_interceptor):
1629 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)))
1630 flow_token = res.flow_token
1631 assert flow_token
1633 # 2. Confirm the email
1634 with session_scope() as session:
1635 email_token = session.execute(
1636 select(SignupFlow.email_token).where(SignupFlow.flow_token == flow_token)
1637 ).scalar_one()
1639 with auth_api_session() as (auth_api, metadata_interceptor):
1640 auth_api.SignupFlow(
1641 auth_pb2.SignupFlowReq(
1642 flow_token=flow_token,
1643 email_token=email_token,
1644 )
1645 )
1647 # 3. Start a new signup with the same email
1648 with auth_api_session() as (auth_api, metadata_interceptor):
1649 with pytest.raises(grpc.RpcError) as e:
1650 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)))
1651 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1652 assert e.value.details() == "Please check your email for a link to continue signing up."
1655@pytest.mark.parametrize("opt_out", [True, False])
1656def test_opt_out_of_newsletter(db, opt_out):
1657 with auth_api_session() as (auth_api, metadata_interceptor):
1658 res = auth_api.SignupFlow(
1659 auth_pb2.SignupFlowReq(
1660 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
1661 account=auth_pb2.SignupAccount(
1662 username="frodo",
1663 password="a very insecure password",
1664 birthdate="1970-01-01",
1665 gender="Bot",
1666 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1667 city="New York City",
1668 lat=40.7331,
1669 lng=-73.9778,
1670 radius=500,
1671 accept_tos=True,
1672 opt_out_of_newsletter=opt_out,
1673 ),
1674 feedback=auth_pb2.ContributorForm(),
1675 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1676 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1677 )
1678 )
1680 with session_scope() as session:
1681 email_token = session.execute(
1682 select(SignupFlow.email_token).where(SignupFlow.flow_token == res.flow_token)
1683 ).scalar_one()
1685 with auth_api_session() as (auth_api, metadata_interceptor):
1686 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1688 user_id = res.auth_res.user_id
1690 with session_scope() as session:
1691 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
1692 assert not user.in_sync_with_newsletter
1693 assert user.opt_out_of_newsletter == opt_out
1696def test_GetAuthState(db):
1697 user, token = generate_user()
1698 jailed_user, jailed_token = generate_user(accepted_tos=0)
1700 with auth_api_session() as (auth_api, metadata_interceptor):
1701 res = auth_api.GetAuthState(empty_pb2.Empty())
1702 assert not res.logged_in
1703 assert not res.HasField("auth_res")
1705 with auth_api_session() as (auth_api, metadata_interceptor):
1706 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),))
1707 assert res.logged_in
1708 assert res.HasField("auth_res")
1709 assert res.auth_res.user_id == user.id
1710 assert not res.auth_res.jailed
1712 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),))
1714 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),))
1715 assert not res.logged_in
1716 assert not res.HasField("auth_res")
1718 with auth_api_session() as (auth_api, metadata_interceptor):
1719 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={jailed_token}"),))
1720 assert res.logged_in
1721 assert res.HasField("auth_res")
1722 assert res.auth_res.user_id == jailed_user.id
1723 assert res.auth_res.jailed
1726def test_signup_no_feedback_regression(db):
1727 """
1728 When we first remove the feedback form, the backned was saying it's not needed but was not completing the signup,
1729 this regression test checks that.
1730 """
1731 with auth_api_session() as (auth_api, metadata_interceptor):
1732 res = auth_api.SignupFlow(
1733 auth_pb2.SignupFlowReq(
1734 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
1735 account=auth_pb2.SignupAccount(
1736 username="frodo",
1737 password="a very insecure password",
1738 birthdate="1970-01-01",
1739 gender="Bot",
1740 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1741 city="New York City",
1742 lat=40.7331,
1743 lng=-73.9778,
1744 radius=500,
1745 accept_tos=True,
1746 ),
1747 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1748 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1749 )
1750 )
1752 flow_token = res.flow_token
1754 assert res.flow_token
1755 assert not res.HasField("auth_res")
1756 assert not res.need_basic
1757 assert not res.need_account
1758 assert not res.need_feedback
1759 assert not res.need_motivations
1760 assert res.need_verify_email
1762 # read out the signup token directly from the database for now
1763 with session_scope() as session:
1764 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1765 assert flow.email_sent
1766 assert not flow.email_verified
1767 email_token = flow.email_token
1769 with auth_api_session() as (auth_api, metadata_interceptor):
1770 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1772 assert not res.flow_token
1773 assert res.HasField("auth_res")
1774 assert res.auth_res.user_id
1775 assert not res.auth_res.jailed
1776 assert not res.need_basic
1777 assert not res.need_account
1778 assert not res.need_feedback
1779 assert not res.need_motivations
1780 assert not res.need_verify_email
1782 # make sure we got the right token in a cookie
1783 with session_scope() as session:
1784 token = session.execute(
1785 select(UserSession.token).join(User, UserSession.user_id == User.id).where(User.username == "frodo")
1786 ).scalar_one()
1787 sesh, uid = get_session_cookie_tokens(metadata_interceptor)
1788 assert sesh == token
1791def test_banned_username(db):
1792 testing_email = f"{random_hex(12)}@couchers.org.invalid"
1793 with auth_api_session() as (auth_api, metadata_interceptor):
1794 reply = auth_api.SignupFlow(
1795 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="Tester", email=testing_email))
1796 )
1798 flow_token = reply.flow_token
1800 with auth_api_session() as (auth_api, metadata_interceptor):
1801 # Banned username
1802 with pytest.raises(grpc.RpcError) as e:
1803 auth_api.SignupFlow(
1804 auth_pb2.SignupFlowReq(
1805 flow_token=flow_token,
1806 account=auth_pb2.SignupAccount(
1807 username="thecouchersadminaccount",
1808 password="a very insecure password",
1809 city="Minas Tirith",
1810 birthdate="1980-12-31",
1811 gender="Robot",
1812 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1813 lat=1,
1814 lng=1,
1815 radius=100,
1816 accept_tos=True,
1817 ),
1818 )
1819 )
1820 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
1821 assert e.value.details() == "Sorry, that username isn't available."
1824# tests for ConfirmChangeEmail within test_account.py tests for test_ChangeEmail_*
1827def test_GetInviteCodeInfo(db):
1828 user, token = generate_user(complete_profile=True)
1830 with account_session(token) as account:
1831 code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code
1833 with auth_api_session() as (auth, _):
1834 res = auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code=code))
1835 assert res.name == user.name
1836 assert res.username == user.username
1837 # Avatar URL should be a thumbnail URL with a hashed filename
1838 assert "/img/thumbnail/" in res.avatar_url
1839 assert res.avatar_url.endswith(".jpg")
1840 # Verify the hashed filename looks correct (64 char hex hash)
1841 assert len(res.avatar_url.split("/")[-1].replace(".jpg", "")) == 64
1842 assert res.url == urls.invite_code_link(code=code)
1845def test_GetInviteCodeInfo_no_avatar(db):
1846 user, token = generate_user(complete_profile=False)
1848 with account_session(token) as account:
1849 code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code
1851 with auth_api_session() as (auth, _):
1852 res = auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code=code))
1853 assert res.name == user.name
1854 assert res.username == user.username
1855 assert res.avatar_url == ""
1856 assert res.url == urls.invite_code_link(code=code)
1859def test_GetInviteCodeInfo_not_found(db):
1860 generate_user()
1862 with auth_api_session() as (auth, _):
1863 with pytest.raises(grpc.RpcError) as e:
1864 auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code="BADCODE"))
1865 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1866 assert e.value.details() == "Invite code not found."
1869def test_SignupFlow_invite_code(db):
1870 user, token = generate_user()
1872 with account_session(token) as account:
1873 invite_code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code
1875 with auth_api_session() as (auth_api, _):
1876 # Signup basic step with invite code
1877 res = auth_api.SignupFlow(
1878 auth_pb2.SignupFlowReq(
1879 basic=auth_pb2.SignupBasic(
1880 name="Test User",
1881 email="inviteuser@example.com",
1882 invite_code=invite_code,
1883 )
1884 )
1885 )
1886 flow_token = res.flow_token
1887 assert flow_token
1889 # Confirm email
1890 with session_scope() as session:
1891 email_token = session.execute(
1892 select(SignupFlow.email_token).where(SignupFlow.flow_token == flow_token)
1893 ).scalar_one()
1895 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1897 # Signup account step
1898 auth_api.SignupFlow(
1899 auth_pb2.SignupFlowReq(
1900 flow_token=flow_token,
1901 account=auth_pb2.SignupAccount(
1902 username="invited_user",
1903 password="secure password",
1904 birthdate="1990-01-01",
1905 gender="Other",
1906 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1907 city="Example City",
1908 lat=1,
1909 lng=5,
1910 radius=100,
1911 accept_tos=True,
1912 ),
1913 feedback=auth_pb2.ContributorForm(),
1914 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1915 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
1916 )
1917 )
1919 # Check that invite_code_id is stored in the final User object
1920 with session_scope() as session:
1921 invite_code_id = session.execute(
1922 select(User.invite_code_id).where(User.username == "invited_user")
1923 ).scalar_one()
1924 assert invite_code_id == invite_code
1927def test_signup_with_motivations(db):
1928 """
1929 Test signup flow with the new motivations step (heard_about_couchers and signup_motivations)
1930 """
1931 with auth_api_session() as (auth_api, metadata_interceptor):
1932 res = auth_api.SignupFlow(
1933 auth_pb2.SignupFlowReq(
1934 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"),
1935 account=auth_pb2.SignupAccount(
1936 username="intentuser",
1937 password="a very insecure password",
1938 birthdate="1970-01-01",
1939 gender="Bot",
1940 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
1941 city="New York City",
1942 lat=40.7331,
1943 lng=-73.9778,
1944 radius=500,
1945 accept_tos=True,
1946 ),
1947 motivations=auth_pb2.SignupMotivations(
1948 heard_about_couchers="friend",
1949 motivations=["hosting", "surfing", "events"],
1950 ),
1951 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
1952 )
1953 )
1955 flow_token = res.flow_token
1956 assert flow_token
1957 assert not res.HasField("auth_res")
1958 assert res.need_verify_email
1960 # Verify the motivations are stored in the SignupFlow
1961 with session_scope() as session:
1962 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
1963 assert flow.heard_about_couchers == "friend"
1964 assert set(flow.signup_motivations) == {"hosting", "surfing", "events"}
1965 email_token = flow.email_token
1967 # Complete signup by verifying email
1968 with auth_api_session() as (auth_api, metadata_interceptor):
1969 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
1971 assert res.HasField("auth_res")
1972 user_id = res.auth_res.user_id
1974 # Verify the motivations are transferred to the User object
1975 with session_scope() as session:
1976 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
1977 assert user.heard_about_couchers == "friend"
1978 assert user.signup_motivations is not None
1979 assert set(user.signup_motivations) == {"hosting", "surfing", "events"}
1982def test_signup_motivations_incremental(db):
1983 """
1984 Test that motivations can be submitted incrementally as a separate step
1985 """
1986 with auth_api_session() as (auth_api, metadata_interceptor):
1987 # First, basic signup
1988 res = auth_api.SignupFlow(
1989 auth_pb2.SignupFlowReq(
1990 basic=auth_pb2.SignupBasic(name="testing", email="email2@couchers.org.invalid"),
1991 )
1992 )
1994 flow_token = res.flow_token
1995 assert flow_token
1996 assert res.need_account
1997 assert res.need_motivations # New field
1999 # Submit motivations separately
2000 with auth_api_session() as (auth_api, metadata_interceptor):
2001 res = auth_api.SignupFlow(
2002 auth_pb2.SignupFlowReq(
2003 flow_token=flow_token,
2004 motivations=auth_pb2.SignupMotivations(
2005 heard_about_couchers="social_media",
2006 motivations=["surfing"],
2007 ),
2008 )
2009 )
2011 assert res.flow_token == flow_token
2012 assert not res.need_motivations # Should be filled now
2013 assert res.need_account # Still need account
2015 # Verify motivations are stored
2016 with session_scope() as session:
2017 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2018 assert flow.heard_about_couchers == "social_media"
2019 assert flow.signup_motivations == ["surfing"]
2022def test_signup_motivations_cannot_be_refilled(db):
2023 """
2024 Test that motivations cannot be submitted twice
2025 """
2026 with auth_api_session() as (auth_api, metadata_interceptor):
2027 res = auth_api.SignupFlow(
2028 auth_pb2.SignupFlowReq(
2029 basic=auth_pb2.SignupBasic(name="testing", email="email3@couchers.org.invalid"),
2030 motivations=auth_pb2.SignupMotivations(
2031 heard_about_couchers="friend",
2032 motivations=["hosting"],
2033 ),
2034 )
2035 )
2037 flow_token = res.flow_token
2039 # Try to submit motivations again - should fail
2040 with auth_api_session() as (auth_api, metadata_interceptor):
2041 with pytest.raises(grpc.RpcError) as e:
2042 auth_api.SignupFlow(
2043 auth_pb2.SignupFlowReq(
2044 flow_token=flow_token,
2045 motivations=auth_pb2.SignupMotivations(
2046 heard_about_couchers="different_source",
2047 motivations=["surfing"],
2048 ),
2049 )
2050 )
2051 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION
2052 assert e.value.details() == "You've already told us about why you are signing up."
2055def test_signup_motivations_required(db):
2056 """
2057 Test that signup cannot complete without providing motivations
2058 """
2059 with auth_api_session() as (auth_api, metadata_interceptor):
2060 res = auth_api.SignupFlow(
2061 auth_pb2.SignupFlowReq(
2062 basic=auth_pb2.SignupBasic(name="testing", email="email4@couchers.org.invalid"),
2063 account=auth_pb2.SignupAccount(
2064 username="nointents",
2065 password="a very insecure password",
2066 birthdate="1970-01-01",
2067 gender="Bot",
2068 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST,
2069 city="New York City",
2070 lat=40.7331,
2071 lng=-73.9778,
2072 radius=500,
2073 accept_tos=True,
2074 ),
2075 # No motivations provided
2076 accept_community_guidelines=wrappers_pb2.BoolValue(value=True),
2077 )
2078 )
2080 flow_token = res.flow_token
2081 assert not res.HasField("auth_res")
2082 assert res.need_motivations # Intents still required
2084 with session_scope() as session:
2085 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2086 email_token = flow.email_token
2088 # Verify email - signup still not complete without motivations
2089 with auth_api_session() as (auth_api, metadata_interceptor):
2090 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token))
2092 assert not res.HasField("auth_res")
2093 assert res.need_motivations
2095 # Now submit motivations
2096 with auth_api_session() as (auth_api, metadata_interceptor):
2097 res = auth_api.SignupFlow(
2098 auth_pb2.SignupFlowReq(
2099 flow_token=flow_token,
2100 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]),
2101 )
2102 )
2104 assert res.HasField("auth_res")
2105 user_id = res.auth_res.user_id
2107 with session_scope() as session:
2108 user = session.execute(select(User).where(User.id == user_id)).scalar_one()
2109 assert user.signup_motivations == ["surfing"]
2112def test_signup_motivations_all_options(db):
2113 """
2114 Test all the different motivation options
2115 """
2116 with auth_api_session() as (auth_api, metadata_interceptor):
2117 res = auth_api.SignupFlow(
2118 auth_pb2.SignupFlowReq(
2119 basic=auth_pb2.SignupBasic(name="testing", email="email5@couchers.org.invalid"),
2120 motivations=auth_pb2.SignupMotivations(
2121 heard_about_couchers="other",
2122 motivations=["hosting", "surfing", "events"],
2123 ),
2124 )
2125 )
2127 flow_token = res.flow_token
2129 with session_scope() as session:
2130 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2131 assert flow.heard_about_couchers == "other"
2132 assert set(flow.signup_motivations) == {"hosting", "surfing", "events"}
2135def test_signup_motivations_empty_motivations_list(db):
2136 """
2137 Test that providing heard_about but empty motivations list is valid
2138 """
2139 with auth_api_session() as (auth_api, metadata_interceptor):
2140 res = auth_api.SignupFlow(
2141 auth_pb2.SignupFlowReq(
2142 basic=auth_pb2.SignupBasic(name="testing", email="email6@couchers.org.invalid"),
2143 motivations=auth_pb2.SignupMotivations(
2144 heard_about_couchers="former_cs_member",
2145 motivations=[], # No specific motivations selected
2146 ),
2147 )
2148 )
2150 flow_token = res.flow_token
2152 with session_scope() as session:
2153 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one()
2154 assert flow.heard_about_couchers == "former_cs_member"
2155 assert flow.signup_motivations == []
2158def test_create_session_does_not_reselect_the_user(db):
2159 """
2160 The commit in create_session expires the user, so anything read off it afterwards re-selects the whole row.
2162 This is on the path of every login, so it was the most executed statement in the query log.
2163 """
2164 user, _ = generate_user()
2166 statements = []
2168 def record(conn, cursor, statement, parameters, context, executemany):
2169 statements.append(statement)
2171 engine = _get_base_engine()
2172 with session_scope() as session:
2173 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one()
2174 context = cast(CouchersContext, _MockCouchersContext())
2175 event.listen(engine, "before_cursor_execute", record)
2176 try:
2177 create_session(context, session, db_user, False)
2178 finally:
2179 event.remove(engine, "before_cursor_execute", record)
2181 assert not [statement for statement in statements if "FROM users" in statement]