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

1import http.cookies 

2from typing import Any, cast 

3from unittest.mock import DEFAULT, patch 

4 

5import grpc 

6import pytest 

7from google.protobuf import empty_pb2, wrappers_pb2 

8from sqlalchemy import event, select, update 

9from sqlalchemy.sql import delete, func 

10 

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) 

42 

43 

44@pytest.fixture(autouse=True) 

45def _(fast_passwords): 

46 pass 

47 

48 

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 

54 

55 

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 

59 

60 with auth_api_session() as (auth_api, metadata_interceptor): 

61 assert not auth_api.UsernameValid(auth_pb2.UsernameValidReq(username="")).valid 

62 

63 

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 ) 

71 

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 

82 

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 

89 

90 with auth_api_session() as (auth_api, metadata_interceptor): 

91 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(flow_token=flow_token)) 

92 

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 

102 

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 ) 

118 

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 

128 

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 ) 

137 

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 

147 

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 ) 

156 

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 

166 

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 ) 

175 

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 

185 

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 ) 

205 

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 

217 

218 user_id = res.auth_res.user_id 

219 

220 sess_token, uid = get_session_cookie_tokens(metadata_interceptor) 

221 assert uid == str(user_id) 

222 

223 with api_session(sess_token) as api: 

224 res = api.GetUser(api_pb2.GetUserReq(user=str(user_id))) 

225 

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 

233 

234 with session_scope() as session: 

235 form = session.execute(select(ContributorForm)).scalar_one() 

236 

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" 

243 

244 

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() 

264 

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 ) 

269 

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() 

289 

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() 

306 

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() 

315 

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() 

319 

320 assert res.HasField("auth_res") 

321 counters["signup_completions_counter"].labels.assert_called_once_with("Bot") 

322 

323 

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 ) 

346 

347 flow_token = res.flow_token 

348 

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 

356 

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 

363 

364 with auth_api_session() as (auth_api, metadata_interceptor): 

365 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token)) 

366 

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 

376 

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 

384 

385 return cast(int, res.auth_res.user_id) 

386 

387 

388def test_signup(db): 

389 _quick_signup() 

390 

391 

392def test_signup_creates_user_moderation_state(db): 

393 user_id = _quick_signup() 

394 

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 

403 

404 

405def test_basic_login(db): 

406 # Create our test user using signup 

407 _quick_signup() 

408 

409 with auth_api_session() as (auth_api, metadata_interceptor): 

410 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password")) 

411 

412 reply_token, _ = get_session_cookie_tokens(metadata_interceptor) 

413 

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 

423 

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}"),)) 

427 

428 

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 ) 

437 

438 flow_token = res.flow_token 

439 assert res.need_verify_email 

440 

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)) 

448 

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." 

454 

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 

459 

460 

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 ) 

480 

481 flow_token = res.flow_token 

482 assert res.need_verify_email 

483 

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." 

489 

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 

493 

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 

499 

500 

501def test_banned_user(db): 

502 user_id = _quick_signup() 

503 

504 with session_scope() as session: 

505 session.execute(select(User)).scalar_one().banned_at = now() 

506 

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." 

512 

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 

519 

520 

521def test_shadowed_user_login_logged(db): 

522 user_id = _quick_signup() 

523 

524 with session_scope() as session: 

525 session.execute(select(User)).scalar_one().shadowed_at = now() 

526 

527 with auth_api_session() as (auth_api, metadata_interceptor): 

528 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password")) 

529 

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 

536 

537 

538def test_deleted_user(db): 

539 user_id = _quick_signup() 

540 

541 with session_scope() as session: 

542 session.execute(update(User).where(User.id == user_id).values(deleted_at=func.now())) 

543 

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." 

549 

550 

551def test_invalid_token(db): 

552 user1, token1 = generate_user() 

553 user2, token2 = generate_user() 

554 

555 wrong_token = random_hex(32) 

556 

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)) 

559 

560 assert e.value.code() == grpc.StatusCode.UNAUTHENTICATED 

561 assert e.value.details() == "Unauthorized" 

562 

563 

564def test_password_reset_v2(db, email_collector: EmailCollector, push_collector: PushCollector): 

565 user, token = generate_user(hashed_password=hash_password("mypassword")) 

566 

567 with auth_api_session() as (auth_api, metadata_interceptor): 

568 res = auth_api.ResetPassword(auth_pb2.ResetPasswordReq(user=user.username)) 

569 

570 with session_scope() as session: 

571 password_reset_token = session.execute(select(PasswordResetToken.token)).scalar_one() 

572 

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 

585 

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." 

589 

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." 

598 

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 ) 

605 

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." 

609 

610 session_token, _ = get_session_cookie_tokens(metadata_interceptor) 

611 

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 

621 

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." 

632 

633 with session_scope() as session: 

634 user = session.execute(select(User)).scalar_one() 

635 assert user.hashed_password == hash_password(pwd) 

636 

637 

638def test_password_reset_no_such_user(db): 

639 user, token = generate_user() 

640 

641 with auth_api_session() as (auth_api, metadata_interceptor): 

642 res = auth_api.ResetPassword( 

643 auth_pb2.ResetPasswordReq( 

644 user="nonexistentuser", 

645 ) 

646 ) 

647 

648 with session_scope() as session: 

649 assert session.execute(select(PasswordResetToken)).scalar_one_or_none() is None 

650 

651 

652def test_password_reset_invalid_token_v2(db): 

653 password = random_hex() 

654 user, token = generate_user(hashed_password=hash_password(password)) 

655 

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 ) 

662 

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." 

667 

668 with session_scope() as session: 

669 user = session.execute(select(User)).scalar_one() 

670 assert user.hashed_password == hash_password(password) 

671 

672 

673def test_logout_invalid_token(db): 

674 # Create our test user using signup 

675 _quick_signup() 

676 

677 with auth_api_session() as (auth_api, metadata_interceptor): 

678 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="a very insecure password")) 

679 

680 reply_token, _ = get_session_cookie_tokens(metadata_interceptor) 

681 

682 # delete all login tokens 

683 with session_scope() as session: 

684 session.execute(delete(LoginToken)) 

685 

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}"),)) 

689 

690 reply_token, _ = get_session_cookie_tokens(metadata_interceptor) 

691 # make sure we set an empty cookie 

692 assert reply_token == "" 

693 

694 

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." 

718 

719 

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." 

743 

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 ) 

762 

763 assert res.flow_token 

764 

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." 

786 

787 with session_scope() as session: 

788 assert session.execute(select(func.count()).select_from(SignupFlow)).scalar_one() == 1 

789 

790 

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." 

797 

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." 

803 

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." 

809 

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." 

815 

816 

817def test_signup_existing_email(db): 

818 # Signed up user 

819 user, _ = generate_user() 

820 

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!" 

826 

827 

828def test_signup_banned_user_email(db): 

829 user, _ = generate_user() 

830 

831 with session_scope() as session: 

832 session.execute(update(User).where(User.id == user.id).values(banned_at=func.now())) 

833 

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." 

839 

840 

841def test_signup_deleted_user_email(db): 

842 user, _ = generate_user() 

843 

844 with session_scope() as session: 

845 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now())) 

846 

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." 

852 

853 

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 

860 

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." 

869 

870 

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 ) 

893 

894 email_collector.pop_for_recipient("email@couchers.org.invalid", last=True) 

895 

896 flow_token = res.flow_token 

897 assert flow_token 

898 

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 

905 

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 ) 

914 

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 

919 

920 with session_scope() as session: 

921 flow = session.execute(select(SignupFlow)).scalar_one() 

922 assert not flow.email_verified 

923 

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 ) 

930 

931 assert not res.flow_token 

932 assert res.HasField("auth_res") 

933 

934 

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" 

938 

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 ) 

949 

950 flow_token = res.flow_token 

951 assert flow_token 

952 

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() 

956 

957 old_email_token = flow.email_token 

958 assert flow.email == old_email 

959 assert old_email_token 

960 

961 # Consume the initial email so we can specifically check the new email below. 

962 email_collector.pop_for_recipient(old_email, last=True) 

963 

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 ) 

972 

973 assert res.flow_token == flow_token 

974 assert res.need_verify_email 

975 

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() 

979 

980 assert flow.email == new_email 

981 assert flow.email != old_email 

982 

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 

987 

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 ) 

996 

997 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

998 

999 # The new email should receive a verification link containing the new token. 

1000 email = email_collector.pop_for_recipient(new_email, last=True) 

1001 

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 

1007 

1008 

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" 

1012 

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 ) 

1023 

1024 flow_token = res.flow_token 

1025 assert flow_token 

1026 

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 ) 

1038 

1039 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

1040 assert e.value.details() == "Invalid email." 

1041 

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() 

1045 

1046 assert flow.email == old_email 

1047 

1048 

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" 

1052 

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 ) 

1063 

1064 flow_token = res.flow_token 

1065 assert flow_token 

1066 

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() 

1070 

1071 email_token = flow.email_token 

1072 assert email_token 

1073 assert not flow.email_verified 

1074 

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 ) 

1082 

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() 

1086 

1087 assert flow.email_verified 

1088 

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." 

1102 

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() 

1106 

1107 assert flow.email == old_email 

1108 assert flow.email_verified 

1109 

1110 

1111_INVALID_SIGNUP_REQUEST = ( 

1112 "Invalid signup request, you cannot recover your signup and continue signing up simultaneously." 

1113) 

1114 

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"] 

1127 

1128 

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) 

1134 

1135 

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) 

1140 

1141 with session_scope() as session: 

1142 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1143 

1144 original_email_token = flow.email_token 

1145 assert original_email_token 

1146 

1147 # Consume the initial email so we can specifically check the one sent below. 

1148 email_collector.pop_for_recipient(testing_email, last=True) 

1149 

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." 

1160 

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 

1166 

1167 with session_scope() as session: 

1168 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1169 

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 

1174 

1175 

1176def test_signup_change_email_to_existing_user_email(db): 

1177 user, _ = generate_user() 

1178 

1179 testing_email = f"{random_hex(12)}@couchers.org.invalid" 

1180 flow_token = _start_signup_flow(testing_email) 

1181 

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!" 

1192 

1193 with session_scope() as session: 

1194 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1195 

1196 assert flow.email == testing_email 

1197 assert flow.email_changed_count == 0 

1198 

1199 

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() 

1203 

1204 with session_scope() as session: 

1205 session.execute(update(User).where(User.id == user.id).values(**{invisible_column: func.now()})) 

1206 

1207 testing_email = f"{random_hex(12)}@couchers.org.invalid" 

1208 flow_token = _start_signup_flow(testing_email) 

1209 

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." 

1220 

1221 with session_scope() as session: 

1222 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1223 

1224 assert flow.email == testing_email 

1225 assert flow.email_changed_count == 0 

1226 

1227 

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" 

1232 

1233 flow_token = _start_signup_flow(testing_email) 

1234 other_flow_token = _start_signup_flow(other_email) 

1235 

1236 with session_scope() as session: 

1237 other_flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == other_flow_token)).scalar_one() 

1238 

1239 other_email_token = other_flow.email_token 

1240 assert other_email_token 

1241 

1242 email_collector.pop_for_recipient(other_email, last=True) 

1243 

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." 

1254 

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 

1259 

1260 with session_scope() as session: 

1261 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1262 

1263 assert flow.email == testing_email 

1264 assert flow.email_changed_count == 0 

1265 

1266 

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." 

1277 

1278 

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) 

1283 

1284 email_collector.pop_for_recipient(testing_email, last=True) 

1285 

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 

1297 

1298 assert email_collector.count() == 0 

1299 

1300 with session_scope() as session: 

1301 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1302 

1303 assert flow.email == testing_email 

1304 assert flow.email_changed_count == 0 

1305 

1306 

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) 

1312 

1313 with session_scope() as session: 

1314 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1315 

1316 email_token = flow.email_token 

1317 assert email_token 

1318 

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 

1324 

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() 

1328 

1329 assert flow.email == testing_email 

1330 assert flow.email_token == email_token 

1331 assert not flow.email_verified 

1332 

1333 

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) 

1340 

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 

1354 

1355 with session_scope() as session: 

1356 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1357 

1358 assert flow.email == testing_email 

1359 assert flow.email_changed_count == 0 

1360 assert not flow.account_is_filled 

1361 

1362 

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") 

1366 

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 

1378 

1379 assert counters["signup_email_changes_counter"].inc.call_count == 3 

1380 

1381 with session_scope() as session: 

1382 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1383 

1384 assert flow.email == new_email 

1385 assert flow.email_changed_count == 3 

1386 

1387 

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" 

1391 

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 ) 

1413 

1414 flow_token = res.flow_token 

1415 assert flow_token 

1416 assert res.need_verify_email 

1417 

1418 email_collector.pop_for_recipient(old_email, last=True) 

1419 

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 ) 

1427 

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") 

1432 

1433 with session_scope() as session: 

1434 flow = session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one() 

1435 

1436 email_token = flow.email_token 

1437 assert email_token 

1438 

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 

1442 

1443 with auth_api_session() as (auth_api, metadata_interceptor): 

1444 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token)) 

1445 

1446 assert not res.flow_token 

1447 assert res.HasField("auth_res") 

1448 

1449 with session_scope() as session: 

1450 user = session.execute(select(User).where(User.username == "frodo")).scalar_one() 

1451 

1452 assert user.email == new_email 

1453 

1454 

1455def test_successful_authenticate(db): 

1456 user, _ = generate_user(hashed_password=hash_password("password")) 

1457 

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 

1462 

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 

1467 

1468 

1469def test_unsuccessful_authenticate(db): 

1470 user, _ = generate_user(hashed_password=hash_password("password")) 

1471 

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." 

1478 

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." 

1485 

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." 

1494 

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." 

1501 

1502 

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 ) 

1509 

1510 flow_token = reply.flow_token 

1511 

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." 

1534 

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." 

1545 

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." 

1568 

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." 

1592 

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." 

1615 

1616 

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'` 

1624 

1625 testing_email = f"{random_hex(12)}@couchers.org.invalid" 

1626 

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 

1632 

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() 

1638 

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 ) 

1646 

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." 

1653 

1654 

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 ) 

1679 

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() 

1684 

1685 with auth_api_session() as (auth_api, metadata_interceptor): 

1686 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token)) 

1687 

1688 user_id = res.auth_res.user_id 

1689 

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 

1694 

1695 

1696def test_GetAuthState(db): 

1697 user, token = generate_user() 

1698 jailed_user, jailed_token = generate_user(accepted_tos=0) 

1699 

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") 

1704 

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 

1711 

1712 auth_api.Deauthenticate(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),)) 

1713 

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") 

1717 

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 

1724 

1725 

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 ) 

1751 

1752 flow_token = res.flow_token 

1753 

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 

1761 

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 

1768 

1769 with auth_api_session() as (auth_api, metadata_interceptor): 

1770 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token)) 

1771 

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 

1781 

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 

1789 

1790 

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 ) 

1797 

1798 flow_token = reply.flow_token 

1799 

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." 

1822 

1823 

1824# tests for ConfirmChangeEmail within test_account.py tests for test_ChangeEmail_* 

1825 

1826 

1827def test_GetInviteCodeInfo(db): 

1828 user, token = generate_user(complete_profile=True) 

1829 

1830 with account_session(token) as account: 

1831 code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code 

1832 

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) 

1843 

1844 

1845def test_GetInviteCodeInfo_no_avatar(db): 

1846 user, token = generate_user(complete_profile=False) 

1847 

1848 with account_session(token) as account: 

1849 code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code 

1850 

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) 

1857 

1858 

1859def test_GetInviteCodeInfo_not_found(db): 

1860 generate_user() 

1861 

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." 

1867 

1868 

1869def test_SignupFlow_invite_code(db): 

1870 user, token = generate_user() 

1871 

1872 with account_session(token) as account: 

1873 invite_code = account.CreateInviteCode(account_pb2.CreateInviteCodeReq()).code 

1874 

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 

1888 

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() 

1894 

1895 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token)) 

1896 

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 ) 

1918 

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 

1925 

1926 

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 ) 

1954 

1955 flow_token = res.flow_token 

1956 assert flow_token 

1957 assert not res.HasField("auth_res") 

1958 assert res.need_verify_email 

1959 

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 

1966 

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)) 

1970 

1971 assert res.HasField("auth_res") 

1972 user_id = res.auth_res.user_id 

1973 

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"} 

1980 

1981 

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 ) 

1993 

1994 flow_token = res.flow_token 

1995 assert flow_token 

1996 assert res.need_account 

1997 assert res.need_motivations # New field 

1998 

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 ) 

2010 

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 

2014 

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"] 

2020 

2021 

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 ) 

2036 

2037 flow_token = res.flow_token 

2038 

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." 

2053 

2054 

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 ) 

2079 

2080 flow_token = res.flow_token 

2081 assert not res.HasField("auth_res") 

2082 assert res.need_motivations # Intents still required 

2083 

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 

2087 

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)) 

2091 

2092 assert not res.HasField("auth_res") 

2093 assert res.need_motivations 

2094 

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 ) 

2103 

2104 assert res.HasField("auth_res") 

2105 user_id = res.auth_res.user_id 

2106 

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"] 

2110 

2111 

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 ) 

2126 

2127 flow_token = res.flow_token 

2128 

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"} 

2133 

2134 

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 ) 

2149 

2150 flow_token = res.flow_token 

2151 

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 == [] 

2156 

2157 

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. 

2161 

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() 

2165 

2166 statements = [] 

2167 

2168 def record(conn, cursor, statement, parameters, context, executemany): 

2169 statements.append(statement) 

2170 

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) 

2180 

2181 assert not [statement for statement in statements if "FROM users" in statement]