Coverage for app/backend/src/tests/test_auth.py: 100%

1056 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1import http.cookies 

2from datetime import timedelta 

3from typing import Any, cast 

4from unittest.mock import DEFAULT, patch 

5 

6import grpc 

7import pytest 

8from google.protobuf import empty_pb2, wrappers_pb2 

9from sqlalchemy import event, select, update 

10from sqlalchemy.sql import delete, func 

11 

12from couchers import urls 

13from couchers.context import CouchersContext 

14from couchers.crypto import hash_password, random_hex 

15from couchers.db import _get_base_engine, session_scope 

16from couchers.models import ( 

17 ContributeOption, 

18 ContributorForm, 

19 LoginToken, 

20 ModerationObjectType, 

21 ModerationState, 

22 NonvisibleUserAccess, 

23 NonvisibleUserAccessType, 

24 NonvisibleUserState, 

25 PasswordResetToken, 

26 SignupFlow, 

27 User, 

28 UserSession, 

29) 

30from couchers.proto import account_pb2, api_pb2, auth_pb2 

31from couchers.servicers.auth import create_session 

32from couchers.utils import now 

33from tests.fixtures.db import generate_user 

34from tests.fixtures.misc import EmailCollector, PushCollector 

35from tests.fixtures.sessions import ( 

36 MetadataKeeperInterceptor, 

37 _MockCouchersContext, 

38 account_session, 

39 api_session, 

40 auth_api_session, 

41 real_api_session, 

42) 

43 

44 

45@pytest.fixture(autouse=True) 

46def _(fast_passwords): 

47 pass 

48 

49 

50def get_session_cookie_tokens(metadata_interceptor: MetadataKeeperInterceptor) -> tuple[str, str]: 

51 set_cookies = [val for key, val in metadata_interceptor.latest_header_raw if key == "set-cookie"] 

52 sesh = http.cookies.SimpleCookie([v for v in set_cookies if "sesh" in v][0])["couchers-sesh"].value 

53 uid = http.cookies.SimpleCookie([v for v in set_cookies if "user-id" in v][0])["couchers-user-id"].value 

54 return sesh, uid 

55 

56 

57def test_UsernameValid(db): 

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

59 assert auth_api.UsernameValid(auth_pb2.UsernameValidReq(username="test")).valid 

60 

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

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

63 

64 

65def test_signup_incremental(db): 

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

67 res = auth_api.SignupFlow( 

68 auth_pb2.SignupFlowReq( 

69 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

70 ) 

71 ) 

72 

73 flow_token = res.flow_token 

74 assert res.flow_token 

75 assert not res.HasField("auth_res") 

76 assert res.email == "email@couchers.org.invalid" 

77 assert not res.need_basic 

78 assert res.need_account 

79 assert not res.need_feedback 

80 assert res.need_verify_email 

81 assert res.need_accept_community_guidelines 

82 assert res.need_motivations 

83 

84 # read out the signup token directly from the database for now 

85 with session_scope() as session: 

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

87 assert flow.email_sent 

88 assert not flow.email_verified 

89 email_token = flow.email_token 

90 

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

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

93 

94 assert res.flow_token == flow_token 

95 assert not res.HasField("auth_res") 

96 assert res.email == "email@couchers.org.invalid" 

97 assert not res.need_basic 

98 assert res.need_account 

99 assert not res.need_feedback 

100 assert res.need_verify_email 

101 assert res.need_accept_community_guidelines 

102 assert res.need_motivations 

103 

104 # Add feedback 

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

106 res = auth_api.SignupFlow( 

107 auth_pb2.SignupFlowReq( 

108 flow_token=flow_token, 

109 feedback=auth_pb2.ContributorForm( 

110 ideas="I'm a robot, incapable of original ideation", 

111 features="I love all your features", 

112 experience="I haven't done couch surfing before", 

113 contribute=auth_pb2.CONTRIBUTE_OPTION_YES, 

114 contribute_ways=["serving", "backend"], 

115 expertise="I'd love to be your server: I can compute very fast, but only simple opcodes", 

116 ), 

117 ) 

118 ) 

119 

120 assert res.flow_token == flow_token 

121 assert not res.HasField("auth_res") 

122 assert res.email == "email@couchers.org.invalid" 

123 assert not res.need_basic 

124 assert res.need_account 

125 assert not res.need_feedback 

126 assert res.need_verify_email 

127 assert res.need_accept_community_guidelines 

128 assert res.need_motivations 

129 

130 # Agree to community guidelines 

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

132 res = auth_api.SignupFlow( 

133 auth_pb2.SignupFlowReq( 

134 flow_token=flow_token, 

135 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

136 ) 

137 ) 

138 

139 assert res.flow_token == flow_token 

140 assert not res.HasField("auth_res") 

141 assert res.email == "email@couchers.org.invalid" 

142 assert not res.need_basic 

143 assert res.need_account 

144 assert not res.need_feedback 

145 assert res.need_verify_email 

146 assert not res.need_accept_community_guidelines 

147 assert res.need_motivations 

148 

149 # Submit motivations 

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

151 res = auth_api.SignupFlow( 

152 auth_pb2.SignupFlowReq( 

153 flow_token=flow_token, 

154 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

155 ) 

156 ) 

157 

158 assert res.flow_token == flow_token 

159 assert not res.HasField("auth_res") 

160 assert res.email == "email@couchers.org.invalid" 

161 assert not res.need_basic 

162 assert res.need_account 

163 assert not res.need_feedback 

164 assert res.need_verify_email 

165 assert not res.need_accept_community_guidelines 

166 assert not res.need_motivations 

167 

168 # Verify email 

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

170 res = auth_api.SignupFlow( 

171 auth_pb2.SignupFlowReq( 

172 flow_token=flow_token, 

173 email_token=email_token, 

174 ) 

175 ) 

176 

177 assert res.flow_token == flow_token 

178 assert not res.HasField("auth_res") 

179 assert res.email == "email@couchers.org.invalid" 

180 assert not res.need_basic 

181 assert res.need_account 

182 assert not res.need_feedback 

183 assert not res.need_verify_email 

184 assert not res.need_accept_community_guidelines 

185 assert not res.need_motivations 

186 

187 # Finally finish off account info 

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

189 res = auth_api.SignupFlow( 

190 auth_pb2.SignupFlowReq( 

191 flow_token=flow_token, 

192 account=auth_pb2.SignupAccount( 

193 username="frodo", 

194 password="a very insecure password", 

195 birthdate="1970-01-01", 

196 gender="Bot", 

197 hosting_status=api_pb2.HOSTING_STATUS_MAYBE, 

198 city="New York City", 

199 lat=40.7331, 

200 lng=-73.9778, 

201 radius=500, 

202 accept_tos=True, 

203 ), 

204 ) 

205 ) 

206 

207 assert not res.flow_token 

208 assert res.HasField("auth_res") 

209 assert not res.email 

210 assert res.auth_res.user_id 

211 assert not res.auth_res.jailed 

212 assert not res.need_basic 

213 assert not res.need_account 

214 assert not res.need_feedback 

215 assert not res.need_verify_email 

216 assert not res.need_accept_community_guidelines 

217 assert not res.need_motivations 

218 

219 user_id = res.auth_res.user_id 

220 

221 sess_token, uid = get_session_cookie_tokens(metadata_interceptor) 

222 assert uid == str(user_id) 

223 

224 with api_session(sess_token) as api: 

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

226 

227 assert res.username == "frodo" 

228 assert res.gender == "Bot" 

229 assert res.hosting_status == api_pb2.HOSTING_STATUS_MAYBE 

230 assert res.city == "New York City" 

231 assert res.lat == 40.7331 

232 assert res.lng == -73.9778 

233 assert res.radius == 500 

234 

235 with session_scope() as session: 

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

237 

238 assert form.ideas == "I'm a robot, incapable of original ideation" 

239 assert form.features == "I love all your features" 

240 assert form.experience == "I haven't done couch surfing before" 

241 assert form.contribute == ContributeOption.yes 

242 assert form.contribute_ways == ["serving", "backend"] 

243 assert form.expertise == "I'd love to be your server: I can compute very fast, but only simple opcodes" 

244 

245 

246def test_signup_funnel_counters(db): 

247 """Each per-step signup funnel counter should fire exactly once across an incremental signup.""" 

248 with patch.multiple( 

249 "couchers.servicers.auth", 

250 signup_initiations_counter=DEFAULT, 

251 signup_account_filled_counter=DEFAULT, 

252 signup_email_verified_counter=DEFAULT, 

253 signup_guidelines_accepted_counter=DEFAULT, 

254 signup_motivations_filled_counter=DEFAULT, 

255 signup_completions_counter=DEFAULT, 

256 ) as counters: 

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

258 res = auth_api.SignupFlow( 

259 auth_pb2.SignupFlowReq( 

260 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

261 ) 

262 ) 

263 flow_token = res.flow_token 

264 counters["signup_initiations_counter"].inc.assert_called_once() 

265 

266 with session_scope() as session: 

267 email_token = ( 

268 session.execute(select(SignupFlow).where(SignupFlow.flow_token == flow_token)).scalar_one().email_token 

269 ) 

270 

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

272 auth_api.SignupFlow( 

273 auth_pb2.SignupFlowReq( 

274 flow_token=flow_token, 

275 account=auth_pb2.SignupAccount( 

276 username="frodo", 

277 password="a very insecure password", 

278 birthdate="1970-01-01", 

279 gender="Bot", 

280 hosting_status=api_pb2.HOSTING_STATUS_MAYBE, 

281 city="New York City", 

282 lat=40.7331, 

283 lng=-73.9778, 

284 radius=500, 

285 accept_tos=True, 

286 ), 

287 ) 

288 ) 

289 counters["signup_account_filled_counter"].inc.assert_called_once() 

290 

291 # accept the guidelines twice; the counter must still only fire once 

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

293 auth_api.SignupFlow( 

294 auth_pb2.SignupFlowReq( 

295 flow_token=flow_token, 

296 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

297 ) 

298 ) 

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

300 auth_api.SignupFlow( 

301 auth_pb2.SignupFlowReq( 

302 flow_token=flow_token, 

303 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

304 ) 

305 ) 

306 counters["signup_guidelines_accepted_counter"].inc.assert_called_once() 

307 

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

309 auth_api.SignupFlow( 

310 auth_pb2.SignupFlowReq( 

311 flow_token=flow_token, 

312 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

313 ) 

314 ) 

315 counters["signup_motivations_filled_counter"].inc.assert_called_once() 

316 

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

318 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(flow_token=flow_token, email_token=email_token)) 

319 counters["signup_email_verified_counter"].inc.assert_called_once() 

320 

321 assert res.HasField("auth_res") 

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

323 

324 

325def _quick_signup() -> int: 

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

327 res = auth_api.SignupFlow( 

328 auth_pb2.SignupFlowReq( 

329 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

330 account=auth_pb2.SignupAccount( 

331 username="frodo", 

332 password="a very insecure password", 

333 birthdate="1970-01-01", 

334 gender="Bot", 

335 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

336 city="New York City", 

337 lat=40.7331, 

338 lng=-73.9778, 

339 radius=500, 

340 accept_tos=True, 

341 ), 

342 feedback=auth_pb2.ContributorForm(), 

343 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

344 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

345 ) 

346 ) 

347 

348 flow_token = res.flow_token 

349 

350 assert res.flow_token 

351 assert not res.HasField("auth_res") 

352 assert not res.need_basic 

353 assert not res.need_account 

354 assert not res.need_feedback 

355 assert not res.need_motivations 

356 assert res.need_verify_email 

357 

358 # read out the signup token directly from the database for now 

359 with session_scope() as session: 

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

361 assert flow.email_sent 

362 assert not flow.email_verified 

363 email_token = flow.email_token 

364 

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

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

367 

368 assert not res.flow_token 

369 assert res.HasField("auth_res") 

370 assert res.auth_res.user_id 

371 assert not res.auth_res.jailed 

372 assert not res.need_basic 

373 assert not res.need_account 

374 assert not res.need_feedback 

375 assert not res.need_motivations 

376 assert not res.need_verify_email 

377 

378 # make sure we got the right token in a cookie 

379 with session_scope() as session: 

380 token = session.execute( 

381 select(UserSession.token).join(User, UserSession.user_id == User.id).where(User.username == "frodo") 

382 ).scalar_one() 

383 sesh, uid = get_session_cookie_tokens(metadata_interceptor) 

384 assert sesh == token 

385 

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

387 

388 

389def test_signup(db): 

390 _quick_signup() 

391 

392 

393def test_signup_creates_user_moderation_state(db): 

394 user_id = _quick_signup() 

395 

396 with session_scope() as session: 

397 state = session.execute( 

398 select(ModerationState) 

399 .where(ModerationState.object_type == ModerationObjectType.user) 

400 .where(ModerationState.object_id == user_id) 

401 ).scalar_one() 

402 assert state.visibility is None 

403 assert session.execute(select(User.moderation_state_id).where(User.id == user_id)).scalar_one() == state.id 

404 

405 

406def test_basic_login(db): 

407 # Create our test user using signup 

408 _quick_signup() 

409 

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

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

412 

413 reply_token, _ = get_session_cookie_tokens(metadata_interceptor) 

414 

415 with session_scope() as session: 

416 token = session.execute( 

417 select(UserSession.token) 

418 .join(User, UserSession.user_id == User.id) 

419 .where(User.username == "frodo") 

420 .where(UserSession.token == reply_token) 

421 .where(UserSession.is_valid) 

422 ).scalar_one_or_none() 

423 assert token 

424 

425 # log out 

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

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

428 

429 

430def test_login_part_signed_up_verified_email(db): 

431 """ 

432 If you try to log in but didn't finish signing up, we send you a new email and ask you to finish signing up. 

433 """ 

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

435 res = auth_api.SignupFlow( 

436 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid")) 

437 ) 

438 

439 flow_token = res.flow_token 

440 assert res.need_verify_email 

441 

442 # verify the email 

443 with session_scope() as session: 

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

445 flow_token = flow.flow_token 

446 email_token = flow.email_token 

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

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

449 

450 with EmailCollector() as email_collector: 

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

452 with pytest.raises(grpc.RpcError) as err: 

453 auth_api.Authenticate(auth_pb2.AuthReq(user="email@couchers.org.invalid", password="wrong pwd")) 

454 assert err.value.details() == "Please check your email for a link to continue signing up." 

455 

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

457 assert email.recipient == "email@couchers.org.invalid" 

458 assert flow_token in email.plain 

459 assert flow_token in email.html 

460 

461 

462def test_login_part_signed_up_not_verified_email(db): 

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

464 res = auth_api.SignupFlow( 

465 auth_pb2.SignupFlowReq( 

466 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

467 account=auth_pb2.SignupAccount( 

468 username="frodo", 

469 password="a very insecure password", 

470 birthdate="1999-01-01", 

471 gender="Bot", 

472 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

473 city="New York City", 

474 lat=40.7331, 

475 lng=-73.9778, 

476 radius=500, 

477 accept_tos=True, 

478 ), 

479 ) 

480 ) 

481 

482 flow_token = res.flow_token 

483 assert res.need_verify_email 

484 

485 with EmailCollector() as email_collector: 

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

487 with pytest.raises(grpc.RpcError) as err: 

488 auth_api.Authenticate(auth_pb2.AuthReq(user="frodo", password="wrong pwd")) 

489 assert err.value.details() == "Please check your email for a link to continue signing up." 

490 

491 with session_scope() as session: 

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

493 email_token = flow.email_token 

494 

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

496 assert email.recipient == "email@couchers.org.invalid" 

497 assert email_token 

498 assert email_token in email.plain 

499 assert email_token in email.html 

500 

501 

502def test_banned_user(db): 

503 user_id = _quick_signup() 

504 

505 with session_scope() as session: 

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

507 

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

509 with pytest.raises(grpc.RpcError) as e: 

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

511 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

512 assert e.value.details() == "Your account is suspended." 

513 

514 with session_scope() as session: 

515 access = session.execute(select(NonvisibleUserAccess)).scalar_one() 

516 assert access.access_type == NonvisibleUserAccessType.login_attempt 

517 assert access.target_state == NonvisibleUserState.banned 

518 assert access.target_user_id == user_id 

519 assert access.actor_user_id == user_id 

520 

521 

522def test_shadowed_user_login_logged(db): 

523 user_id = _quick_signup() 

524 

525 with session_scope() as session: 

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

527 

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

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

530 

531 with session_scope() as session: 

532 access = session.execute(select(NonvisibleUserAccess)).scalar_one() 

533 assert access.access_type == NonvisibleUserAccessType.login_attempt 

534 assert access.target_state == NonvisibleUserState.shadowed 

535 assert access.target_user_id == user_id 

536 assert access.actor_user_id == user_id 

537 

538 

539def test_deleted_user(db): 

540 user_id = _quick_signup() 

541 

542 with session_scope() as session: 

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

544 

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

546 with pytest.raises(grpc.RpcError) as e: 

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

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

549 assert e.value.details() == "An account with that username or email was not found." 

550 

551 

552def test_invalid_token(db): 

553 user1, token1 = generate_user() 

554 user2, token2 = generate_user() 

555 

556 wrong_token = random_hex(32) 

557 

558 with real_api_session(wrong_token) as api, pytest.raises(grpc.RpcError) as e: 

559 res = api.GetUser(api_pb2.GetUserReq(user=user2.username)) 

560 

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

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

563 

564 

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

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

567 

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

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

570 

571 with session_scope() as session: 

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

573 

574 email = email_collector.pop_for_recipient(user.email, last=True) 

575 assert email.recipient == user.email 

576 assert "reset" in email.subject.lower() 

577 assert password_reset_token in email.plain 

578 assert password_reset_token in email.html 

579 unique_string = "You asked for your password to be reset on Couchers.org." 

580 assert unique_string in email.plain 

581 assert unique_string in email.html 

582 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in email.plain 

583 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in email.html 

584 assert "support@couchers.org" in email.plain 

585 assert "support@couchers.org" in email.html 

586 

587 push = push_collector.pop_for_user(user.id, last=True) 

588 assert push.content.title == "Password reset requested" 

589 assert push.content.body == "Use the link we sent by email to complete it." 

590 

591 # make sure bad password are caught 

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

593 with pytest.raises(grpc.RpcError) as err: 

594 auth_api.CompletePasswordResetV2( 

595 auth_pb2.CompletePasswordResetV2Req(password_reset_token=password_reset_token, new_password="password") 

596 ) 

597 assert err.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

598 assert err.value.details() == "The password is insecure. Please use one that is not easily guessable." 

599 

600 # make sure we can set a good password 

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

602 pwd = random_hex() 

603 auth_api.CompletePasswordResetV2( 

604 auth_pb2.CompletePasswordResetV2Req(password_reset_token=password_reset_token, new_password=pwd) 

605 ) 

606 

607 push = push_collector.pop_for_user(user.id, last=True) 

608 assert push.content.title == "Password reset" 

609 assert push.content.body == "Your password was successfully reset." 

610 

611 session_token, _ = get_session_cookie_tokens(metadata_interceptor) 

612 

613 with session_scope() as session: 

614 other_session_token = session.execute( 

615 select(UserSession.token) 

616 .join(User, UserSession.user_id == User.id) 

617 .where(User.username == user.username) 

618 .where(UserSession.token == session_token) 

619 .where(UserSession.is_valid) 

620 ).scalar_one_or_none() 

621 assert other_session_token 

622 

623 # make sure we can't set a password again 

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

625 with pytest.raises(grpc.RpcError) as err: 

626 auth_api.CompletePasswordResetV2( 

627 auth_pb2.CompletePasswordResetV2Req( 

628 password_reset_token=password_reset_token, new_password=random_hex() 

629 ) 

630 ) 

631 assert err.value.code() == grpc.StatusCode.NOT_FOUND 

632 assert err.value.details() == "Invalid token." 

633 

634 with session_scope() as session: 

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

636 assert user.hashed_password == hash_password(pwd) 

637 

638 

639def test_password_reset_no_such_user(db): 

640 user, token = generate_user() 

641 

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

643 res = auth_api.ResetPassword( 

644 auth_pb2.ResetPasswordReq( 

645 user="nonexistentuser", 

646 ) 

647 ) 

648 

649 with session_scope() as session: 

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

651 

652 

653def test_password_reset_invalid_token_v2(db): 

654 password = random_hex() 

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

656 

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

658 res = auth_api.ResetPassword( 

659 auth_pb2.ResetPasswordReq( 

660 user=user.username, 

661 ) 

662 ) 

663 

664 with auth_api_session() as (auth_api, metadata_interceptor), pytest.raises(grpc.RpcError) as e: 

665 res = auth_api.CompletePasswordResetV2(auth_pb2.CompletePasswordResetV2Req(password_reset_token="wrongtoken")) 

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

667 assert e.value.details() == "Invalid token." 

668 

669 with session_scope() as session: 

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

671 assert user.hashed_password == hash_password(password) 

672 

673 

674def test_logout_invalid_token(db): 

675 # Create our test user using signup 

676 _quick_signup() 

677 

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

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

680 

681 reply_token, _ = get_session_cookie_tokens(metadata_interceptor) 

682 

683 # delete all login tokens 

684 with session_scope() as session: 

685 session.execute(delete(LoginToken)) 

686 

687 # log out with non-existent token should still return a valid result 

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

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

690 

691 reply_token, _ = get_session_cookie_tokens(metadata_interceptor) 

692 # make sure we set an empty cookie 

693 assert reply_token == "" 

694 

695 

696def test_signup_without_password(db): 

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

698 with pytest.raises(grpc.RpcError) as e: 

699 auth_api.SignupFlow( 

700 auth_pb2.SignupFlowReq( 

701 basic=auth_pb2.SignupBasic(name="Räksmörgås", email="a1@b.com"), 

702 account=auth_pb2.SignupAccount( 

703 username="frodo", 

704 password="bad", 

705 city="Minas Tirith", 

706 birthdate="9999-12-31", # arbitrary future birthdate 

707 gender="Robot", 

708 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

709 lat=1, 

710 lng=1, 

711 radius=100, 

712 accept_tos=True, 

713 ), 

714 feedback=auth_pb2.ContributorForm(), 

715 ) 

716 ) 

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

718 assert e.value.details() == "The password must be 8 or more characters long." 

719 

720 

721def test_signup_invalid_birthdate(db): 

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

723 with pytest.raises(grpc.RpcError) as e: 

724 auth_api.SignupFlow( 

725 auth_pb2.SignupFlowReq( 

726 basic=auth_pb2.SignupBasic(name="Räksmörgås", email="a1@b.com"), 

727 account=auth_pb2.SignupAccount( 

728 username="frodo", 

729 password="a very insecure password", 

730 city="Minas Tirith", 

731 birthdate="9999-12-31", # arbitrary future birthdate 

732 gender="Robot", 

733 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

734 lat=1, 

735 lng=1, 

736 radius=100, 

737 accept_tos=True, 

738 ), 

739 feedback=auth_pb2.ContributorForm(), 

740 ) 

741 ) 

742 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

743 assert e.value.details() == "You must be at least 18 years old to sign up." 

744 

745 res = auth_api.SignupFlow( 

746 auth_pb2.SignupFlowReq( 

747 basic=auth_pb2.SignupBasic(name="Christopher", email="a2@b.com"), 

748 account=auth_pb2.SignupAccount( 

749 username="ceelo", 

750 password="a very insecure password", 

751 city="New York City", 

752 birthdate="2000-12-31", # arbitrary birthdate older than 18 years 

753 gender="Helicopter", 

754 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

755 lat=1, 

756 lng=1, 

757 radius=100, 

758 accept_tos=True, 

759 ), 

760 feedback=auth_pb2.ContributorForm(), 

761 ) 

762 ) 

763 

764 assert res.flow_token 

765 

766 with pytest.raises(grpc.RpcError) as e: 

767 auth_api.SignupFlow( 

768 auth_pb2.SignupFlowReq( 

769 basic=auth_pb2.SignupBasic(name="Franklin", email="a3@b.com"), 

770 account=auth_pb2.SignupAccount( 

771 username="franklin", 

772 password="a very insecure password", 

773 city="Los Santos", 

774 birthdate="2010-04-09", # arbitrary birthdate < 18 yrs 

775 gender="Male", 

776 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

777 lat=1, 

778 lng=1, 

779 radius=100, 

780 accept_tos=True, 

781 ), 

782 feedback=auth_pb2.ContributorForm(), 

783 ) 

784 ) 

785 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

786 assert e.value.details() == "You must be at least 18 years old to sign up." 

787 

788 with session_scope() as session: 

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

790 

791 

792def test_signup_invalid_email(db): 

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

794 with pytest.raises(grpc.RpcError) as e: 

795 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a"))) 

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

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

798 

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

800 with pytest.raises(grpc.RpcError) as e: 

801 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b"))) 

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

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

804 

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

806 with pytest.raises(grpc.RpcError) as e: 

807 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b."))) 

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

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

810 

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

812 with pytest.raises(grpc.RpcError) as e: 

813 reply = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email="a@b.c"))) 

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

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

816 

817 

818def test_signup_existing_email(db): 

819 # Signed up user 

820 user, _ = generate_user() 

821 

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

823 with pytest.raises(grpc.RpcError) as e: 

824 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=user.email))) 

825 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

826 assert e.value.details() == "That email address is already associated with an account. Please log in instead!" 

827 

828 

829def test_signup_banned_user_email(db): 

830 user, _ = generate_user() 

831 

832 with session_scope() as session: 

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

834 

835 with auth_api_session() as (auth_api, _): 

836 with pytest.raises(grpc.RpcError) as e: 

837 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email))) 

838 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

839 assert e.value.details() == "You cannot sign up with that email address." 

840 

841 

842def test_signup_deleted_user_email(db): 

843 user, _ = generate_user(email="email@couchers.org.invalid") 

844 

845 with session_scope() as session: 

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

847 

848 new_user_id = _quick_signup() 

849 assert new_user_id != user.id 

850 

851 with auth_api_session() as (auth_api, _): 

852 res = auth_api.Authenticate(auth_pb2.AuthReq(user=user.email, password="a very insecure password")) 

853 assert res.user_id == new_user_id 

854 

855 

856def test_signup_deleted_shadowed_user_email(db): 

857 user, _ = generate_user(email="email@couchers.org.invalid") 

858 

859 with session_scope() as session: 

860 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now(), shadowed_at=func.now())) 

861 

862 new_user_id = _quick_signup() 

863 

864 with session_scope() as session: 

865 new_user = session.execute(select(User).where(User.id == new_user_id)).scalar_one() 

866 assert new_user.shadowed_at is None 

867 

868 

869def test_signup_deleted_banned_user_email(db): 

870 user, _ = generate_user() 

871 

872 with session_scope() as session: 

873 session.execute(update(User).where(User.id == user.id).values(deleted_at=func.now(), banned_at=func.now())) 

874 

875 with auth_api_session() as (auth_api, _): 

876 with pytest.raises(grpc.RpcError) as e: 

877 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email))) 

878 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

879 assert e.value.details() == "You cannot sign up with that email address." 

880 

881 

882def test_signup_shadowed_user_email(db): 

883 user, _ = generate_user() 

884 

885 with session_scope() as session: 

886 session.execute(update(User).where(User.id == user.id).values(shadowed_at=func.now())) 

887 

888 with auth_api_session() as (auth_api, _): 

889 with pytest.raises(grpc.RpcError) as e: 

890 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="NewName", email=user.email))) 

891 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

892 assert e.value.details() == "That email address is already associated with an account. Please log in instead!" 

893 

894 

895def test_recover_account_email_reused(db): 

896 user, _ = generate_user() 

897 

898 with session_scope() as session: 

899 session.execute( 

900 update(User) 

901 .where(User.id == user.id) 

902 .values(deleted_at=func.now(), undelete_token="token", undelete_until=now() + timedelta(days=1)) 

903 ) 

904 

905 generate_user(email=user.email) 

906 

907 with auth_api_session() as (auth_api, _): 

908 with pytest.raises(grpc.RpcError) as e: 

909 auth_api.RecoverAccount(auth_pb2.RecoverAccountReq(token="token")) 

910 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

911 assert ( 

912 e.value.details() 

913 == "This account can't be recovered because its email address is now used by another account." 

914 ) 

915 

916 with session_scope() as session: 

917 assert session.execute(select(User.deleted_at).where(User.id == user.id)).scalar_one() is not None 

918 

919 

920def test_signup_continue_with_email(db): 

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

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

923 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email))) 

924 flow_token = res.flow_token 

925 assert flow_token 

926 

927 # continue with same email, should just send another email to the user 

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

929 with pytest.raises(grpc.RpcError) as e: 

930 res = auth_api.SignupFlow( 

931 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email)) 

932 ) 

933 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

934 assert e.value.details() == "Please check your email for a link to continue signing up." 

935 

936 

937def test_signup_resend_email(db, email_collector: EmailCollector): 

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

939 res = auth_api.SignupFlow( 

940 auth_pb2.SignupFlowReq( 

941 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

942 account=auth_pb2.SignupAccount( 

943 username="frodo", 

944 password="a very insecure password", 

945 birthdate="1970-01-01", 

946 gender="Bot", 

947 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

948 city="New York City", 

949 lat=40.7331, 

950 lng=-73.9778, 

951 radius=500, 

952 accept_tos=True, 

953 ), 

954 feedback=auth_pb2.ContributorForm(), 

955 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

956 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

957 ) 

958 ) 

959 

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

961 

962 flow_token = res.flow_token 

963 assert flow_token 

964 

965 with session_scope() as session: 

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

967 assert flow.flow_token == flow_token 

968 assert flow.email_sent 

969 assert not flow.email_verified 

970 email_token = flow.email_token 

971 

972 # ask for a new signup email 

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

974 res = auth_api.SignupFlow( 

975 auth_pb2.SignupFlowReq( 

976 flow_token=flow_token, 

977 resend_verification_email=True, 

978 ) 

979 ) 

980 

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

982 assert email_token 

983 assert email_token in email.plain 

984 assert email_token in email.html 

985 

986 with session_scope() as session: 

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

988 assert not flow.email_verified 

989 

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

991 res = auth_api.SignupFlow( 

992 auth_pb2.SignupFlowReq( 

993 email_token=email_token, 

994 ) 

995 ) 

996 

997 assert not res.flow_token 

998 assert res.HasField("auth_res") 

999 

1000 

1001def test_signup_change_email(db, email_collector: EmailCollector): 

1002 old_email = f"{random_hex(12)}@couchers.org.invalid" 

1003 new_email = f"{random_hex(12)}@couchers.org.invalid" 

1004 

1005 # Start a signup with the old email. 

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

1007 res = auth_api.SignupFlow( 

1008 auth_pb2.SignupFlowReq( 

1009 basic=auth_pb2.SignupBasic( 

1010 name="testing", 

1011 email=old_email, 

1012 ) 

1013 ) 

1014 ) 

1015 

1016 flow_token = res.flow_token 

1017 assert flow_token 

1018 

1019 # Get the original verification token. 

1020 with session_scope() as session: 

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

1022 

1023 old_email_token = flow.email_token 

1024 assert flow.email == old_email 

1025 assert old_email_token 

1026 

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

1028 email_collector.pop_for_recipient(old_email, last=True) 

1029 

1030 # Change the signup email. 

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

1032 res = auth_api.SignupFlow( 

1033 auth_pb2.SignupFlowReq( 

1034 flow_token=flow_token, 

1035 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email), 

1036 ) 

1037 ) 

1038 

1039 assert res.flow_token == flow_token 

1040 assert res.need_verify_email 

1041 

1042 # The signup should now have the new email and a new verification token. 

1043 with session_scope() as session: 

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

1045 

1046 assert flow.email == new_email 

1047 assert flow.email != old_email 

1048 

1049 new_email_token = flow.email_token 

1050 assert new_email_token 

1051 assert new_email_token != old_email_token 

1052 assert flow.email_changed_count == 1 

1053 

1054 # The old token should no longer be usable. 

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

1056 with pytest.raises(grpc.RpcError) as e: 

1057 auth_api.SignupFlow( 

1058 auth_pb2.SignupFlowReq( 

1059 email_token=old_email_token, 

1060 ) 

1061 ) 

1062 

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

1064 

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

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

1067 

1068 assert email.recipient == new_email 

1069 assert new_email_token in email.plain 

1070 assert new_email_token in email.html 

1071 assert old_email_token not in email.plain 

1072 assert old_email_token not in email.html 

1073 

1074 

1075@pytest.mark.parametrize("invalid_email", ["bad email", "a@b", "a@b.", "@ab.cd", "a@b.c"]) 

1076def test_signup_change_new_invalid_email(db, invalid_email): 

1077 old_email = f"{random_hex(12)}@couchers.org.invalid" 

1078 

1079 # Create a signup with a valid email first. 

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

1081 res = auth_api.SignupFlow( 

1082 auth_pb2.SignupFlowReq( 

1083 basic=auth_pb2.SignupBasic( 

1084 name="frodo", 

1085 email=old_email, 

1086 ) 

1087 ) 

1088 ) 

1089 

1090 flow_token = res.flow_token 

1091 assert flow_token 

1092 

1093 # Try changing to an invalid email. 

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

1095 with pytest.raises(grpc.RpcError) as e: 

1096 auth_api.SignupFlow( 

1097 auth_pb2.SignupFlowReq( 

1098 flow_token=flow_token, 

1099 change_email=auth_pb2.ChangeSignupEmail( 

1100 new_email=invalid_email, 

1101 ), 

1102 ) 

1103 ) 

1104 

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

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

1107 

1108 # Make sure the original email wasn't changed. 

1109 with session_scope() as session: 

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

1111 

1112 assert flow.email == old_email 

1113 

1114 

1115def test_signup_change_email_after_email_verified(db): 

1116 old_email = f"{random_hex(12)}@couchers.org.invalid" 

1117 new_email = f"{random_hex(12)}@couchers.org.invalid" 

1118 

1119 # Start a signup. 

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

1121 res = auth_api.SignupFlow( 

1122 auth_pb2.SignupFlowReq( 

1123 basic=auth_pb2.SignupBasic( 

1124 name="testing", 

1125 email=old_email, 

1126 ) 

1127 ) 

1128 ) 

1129 

1130 flow_token = res.flow_token 

1131 assert flow_token 

1132 

1133 # Get the verification token. 

1134 with session_scope() as session: 

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

1136 

1137 email_token = flow.email_token 

1138 assert email_token 

1139 assert not flow.email_verified 

1140 

1141 # Verify the original email. 

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

1143 res = auth_api.SignupFlow( 

1144 auth_pb2.SignupFlowReq( 

1145 email_token=email_token, 

1146 ) 

1147 ) 

1148 

1149 # The email should now be verified. 

1150 with session_scope() as session: 

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

1152 

1153 assert flow.email_verified 

1154 

1155 # Changing the email is no longer allowed. 

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

1157 with pytest.raises(grpc.RpcError) as e: 

1158 auth_api.SignupFlow( 

1159 auth_pb2.SignupFlowReq( 

1160 flow_token=flow_token, 

1161 change_email=auth_pb2.ChangeSignupEmail( 

1162 new_email=new_email, 

1163 ), 

1164 ) 

1165 ) 

1166 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1167 assert e.value.details() == "You have already verified your email." 

1168 

1169 # Make sure the signup email wasn't changed. 

1170 with session_scope() as session: 

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

1172 

1173 assert flow.email == old_email 

1174 assert flow.email_verified 

1175 

1176 

1177_INVALID_SIGNUP_REQUEST = ( 

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

1179) 

1180 

1181# a sample value for every field that mutates a signup flow 

1182_SIGNUP_FLOW_FIELDS: dict[str, Any] = { 

1183 "basic": auth_pb2.SignupBasic(name="testing", email="other@couchers.org.invalid"), 

1184 "account": auth_pb2.SignupAccount(username="frodo"), 

1185 "feedback": auth_pb2.ContributorForm(), 

1186 "motivations": auth_pb2.SignupMotivations(motivations=["surfing"]), 

1187 "accept_community_guidelines": wrappers_pb2.BoolValue(value=True), 

1188 "change_email": auth_pb2.ChangeSignupEmail(new_email="other@couchers.org.invalid"), 

1189 "resend_verification_email": True, 

1190} 

1191_SIGNUP_STEP_FIELDS = ["basic", "account", "feedback", "motivations", "accept_community_guidelines"] 

1192_RECOVERY_STEP_FIELDS = ["change_email", "resend_verification_email"] 

1193 

1194 

1195def _start_signup_flow(email: str) -> str: 

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

1197 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="testing", email=email))) 

1198 assert res.flow_token 

1199 return cast(str, res.flow_token) 

1200 

1201 

1202def test_signup_change_email_same_email(db, email_collector: EmailCollector): 

1203 """Changing to the email the flow already has is rejected, but the existing link is resent.""" 

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

1205 flow_token = _start_signup_flow(testing_email) 

1206 

1207 with session_scope() as session: 

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

1209 

1210 original_email_token = flow.email_token 

1211 assert original_email_token 

1212 

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

1214 email_collector.pop_for_recipient(testing_email, last=True) 

1215 

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

1217 with pytest.raises(grpc.RpcError) as e: 

1218 auth_api.SignupFlow( 

1219 auth_pb2.SignupFlowReq( 

1220 flow_token=flow_token, 

1221 change_email=auth_pb2.ChangeSignupEmail(new_email=testing_email), 

1222 ) 

1223 ) 

1224 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1225 assert e.value.details() == "Please check your email for a link to continue signing up." 

1226 

1227 # The existing verification link is resent. 

1228 email = email_collector.pop_for_recipient(testing_email, last=True) 

1229 assert email.recipient == testing_email 

1230 assert original_email_token in email.plain 

1231 assert original_email_token in email.html 

1232 

1233 with session_scope() as session: 

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

1235 

1236 assert flow.email == testing_email 

1237 assert flow.email_token == original_email_token 

1238 assert not flow.email_verified 

1239 assert flow.email_changed_count == 0 

1240 

1241 

1242def test_signup_change_email_to_existing_user_email(db): 

1243 user, _ = generate_user() 

1244 

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

1246 flow_token = _start_signup_flow(testing_email) 

1247 

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

1249 with pytest.raises(grpc.RpcError) as e: 

1250 auth_api.SignupFlow( 

1251 auth_pb2.SignupFlowReq( 

1252 flow_token=flow_token, 

1253 change_email=auth_pb2.ChangeSignupEmail(new_email=user.email), 

1254 ) 

1255 ) 

1256 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1257 assert e.value.details() == "That email address is already associated with an account. Please log in instead!" 

1258 

1259 with session_scope() as session: 

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

1261 

1262 assert flow.email == testing_email 

1263 assert flow.email_changed_count == 0 

1264 

1265 

1266@pytest.mark.parametrize("columns", [["banned_at"], ["banned_at", "deleted_at"]]) 

1267def test_signup_change_email_to_banned_user_email(db, columns): 

1268 user, _ = generate_user() 

1269 

1270 with session_scope() as session: 

1271 session.execute(update(User).where(User.id == user.id).values(**{column: func.now() for column in columns})) 

1272 

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

1274 flow_token = _start_signup_flow(testing_email) 

1275 

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

1277 with pytest.raises(grpc.RpcError) as e: 

1278 auth_api.SignupFlow( 

1279 auth_pb2.SignupFlowReq( 

1280 flow_token=flow_token, 

1281 change_email=auth_pb2.ChangeSignupEmail(new_email=user.email), 

1282 ) 

1283 ) 

1284 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1285 assert e.value.details() == "You cannot sign up with that email address." 

1286 

1287 with session_scope() as session: 

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

1289 

1290 assert flow.email == testing_email 

1291 assert flow.email_changed_count == 0 

1292 

1293 

1294def test_signup_change_email_to_other_flow_email(db, email_collector: EmailCollector): 

1295 """Changing to an email that another signup already uses nudges that other signup instead.""" 

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

1297 other_email = f"{random_hex(12)}@couchers.org.invalid" 

1298 

1299 flow_token = _start_signup_flow(testing_email) 

1300 other_flow_token = _start_signup_flow(other_email) 

1301 

1302 with session_scope() as session: 

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

1304 

1305 other_email_token = other_flow.email_token 

1306 assert other_email_token 

1307 

1308 email_collector.pop_for_recipient(other_email, last=True) 

1309 

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

1311 with pytest.raises(grpc.RpcError) as e: 

1312 auth_api.SignupFlow( 

1313 auth_pb2.SignupFlowReq( 

1314 flow_token=flow_token, 

1315 change_email=auth_pb2.ChangeSignupEmail(new_email=other_email), 

1316 ) 

1317 ) 

1318 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1319 assert e.value.details() == "Please check your email for a link to continue signing up." 

1320 

1321 # The other signup gets its own link resent, this one is untouched. 

1322 email = email_collector.pop_for_recipient(other_email, last=True) 

1323 assert other_email_token in email.plain 

1324 assert other_email_token in email.html 

1325 

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_changed_count == 0 

1331 

1332 

1333def test_signup_change_email_without_flow_token(db): 

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

1335 with pytest.raises(grpc.RpcError) as e: 

1336 auth_api.SignupFlow( 

1337 auth_pb2.SignupFlowReq( 

1338 change_email=auth_pb2.ChangeSignupEmail(new_email=f"{random_hex(12)}@couchers.org.invalid"), 

1339 ) 

1340 ) 

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

1342 assert e.value.details() == "Account details needed to sign up." 

1343 

1344 

1345def test_signup_change_email_and_resend_verification_email(db, email_collector: EmailCollector): 

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

1347 new_email = f"{random_hex(12)}@couchers.org.invalid" 

1348 flow_token = _start_signup_flow(testing_email) 

1349 

1350 email_collector.pop_for_recipient(testing_email, last=True) 

1351 

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

1353 with pytest.raises(grpc.RpcError) as e: 

1354 auth_api.SignupFlow( 

1355 auth_pb2.SignupFlowReq( 

1356 flow_token=flow_token, 

1357 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email), 

1358 resend_verification_email=True, 

1359 ) 

1360 ) 

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

1362 assert e.value.details() == _INVALID_SIGNUP_REQUEST 

1363 

1364 assert email_collector.count() == 0 

1365 

1366 with session_scope() as session: 

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

1368 

1369 assert flow.email == testing_email 

1370 assert flow.email_changed_count == 0 

1371 

1372 

1373@pytest.mark.parametrize("field", list(_SIGNUP_FLOW_FIELDS)) 

1374def test_signup_email_token_with_other_fields(db, field): 

1375 """The email token makes the rest of the request be ignored, so sending both is rejected.""" 

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

1377 flow_token = _start_signup_flow(testing_email) 

1378 

1379 with session_scope() as session: 

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

1381 

1382 email_token = flow.email_token 

1383 assert email_token 

1384 

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

1386 with pytest.raises(grpc.RpcError) as e: 

1387 auth_api.SignupFlow(auth_pb2.SignupFlowReq(email_token=email_token, **{field: _SIGNUP_FLOW_FIELDS[field]})) 

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

1389 assert e.value.details() == _INVALID_SIGNUP_REQUEST 

1390 

1391 # the token wasn't consumed 

1392 with session_scope() as session: 

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

1394 

1395 assert flow.email == testing_email 

1396 assert flow.email_token == email_token 

1397 assert not flow.email_verified 

1398 

1399 

1400@pytest.mark.parametrize("recovery_field", _RECOVERY_STEP_FIELDS) 

1401@pytest.mark.parametrize("signup_field", _SIGNUP_STEP_FIELDS) 

1402def test_signup_flow_signup_step_with_recovery_step(db, signup_field, recovery_field): 

1403 """Continuing a signup and recovering it in the same request is rejected.""" 

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

1405 flow_token = _start_signup_flow(testing_email) 

1406 

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

1408 with pytest.raises(grpc.RpcError) as e: 

1409 auth_api.SignupFlow( 

1410 auth_pb2.SignupFlowReq( 

1411 flow_token=flow_token, 

1412 **{ 

1413 signup_field: _SIGNUP_FLOW_FIELDS[signup_field], 

1414 recovery_field: _SIGNUP_FLOW_FIELDS[recovery_field], 

1415 }, 

1416 ) 

1417 ) 

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

1419 assert e.value.details() == _INVALID_SIGNUP_REQUEST 

1420 

1421 with session_scope() as session: 

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

1423 

1424 assert flow.email == testing_email 

1425 assert flow.email_changed_count == 0 

1426 assert not flow.account_is_filled 

1427 

1428 

1429def test_signup_change_email_repeatedly(db): 

1430 with patch.multiple("couchers.servicers.auth", signup_email_changes_counter=DEFAULT) as counters: 

1431 flow_token = _start_signup_flow(f"{random_hex(12)}@couchers.org.invalid") 

1432 

1433 for _ in range(3): 

1434 new_email = f"{random_hex(12)}@couchers.org.invalid" 

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

1436 res = auth_api.SignupFlow( 

1437 auth_pb2.SignupFlowReq( 

1438 flow_token=flow_token, 

1439 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email), 

1440 ) 

1441 ) 

1442 assert res.flow_token == flow_token 

1443 assert res.need_verify_email 

1444 

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

1446 

1447 with session_scope() as session: 

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

1449 

1450 assert flow.email == new_email 

1451 assert flow.email_changed_count == 3 

1452 

1453 

1454def test_signup_change_email_then_complete(db, email_collector: EmailCollector): 

1455 old_email = f"{random_hex(12)}@couchers.org.invalid" 

1456 new_email = f"{random_hex(12)}@couchers.org.invalid" 

1457 

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

1459 res = auth_api.SignupFlow( 

1460 auth_pb2.SignupFlowReq( 

1461 basic=auth_pb2.SignupBasic(name="testing", email=old_email), 

1462 account=auth_pb2.SignupAccount( 

1463 username="frodo", 

1464 password="a very insecure password", 

1465 birthdate="1970-01-01", 

1466 gender="Bot", 

1467 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1468 city="New York City", 

1469 lat=40.7331, 

1470 lng=-73.9778, 

1471 radius=500, 

1472 accept_tos=True, 

1473 ), 

1474 feedback=auth_pb2.ContributorForm(), 

1475 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

1476 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

1477 ) 

1478 ) 

1479 

1480 flow_token = res.flow_token 

1481 assert flow_token 

1482 assert res.need_verify_email 

1483 

1484 email_collector.pop_for_recipient(old_email, last=True) 

1485 

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

1487 res = auth_api.SignupFlow( 

1488 auth_pb2.SignupFlowReq( 

1489 flow_token=flow_token, 

1490 change_email=auth_pb2.ChangeSignupEmail(new_email=new_email), 

1491 ) 

1492 ) 

1493 

1494 # the flow is otherwise complete, so it must not finish until the new email is verified 

1495 assert res.flow_token == flow_token 

1496 assert res.need_verify_email 

1497 assert not res.HasField("auth_res") 

1498 

1499 with session_scope() as session: 

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

1501 

1502 email_token = flow.email_token 

1503 assert email_token 

1504 

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

1506 assert email_token in email.plain 

1507 assert email_token in email.html 

1508 

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

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

1511 

1512 assert not res.flow_token 

1513 assert res.HasField("auth_res") 

1514 

1515 with session_scope() as session: 

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

1517 

1518 assert user.email == new_email 

1519 

1520 

1521def test_successful_authenticate(db): 

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

1523 

1524 # Authenticate with username 

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

1526 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.username, password="password")) 

1527 assert not reply.jailed 

1528 

1529 # Authenticate with email 

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

1531 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.email, password="password")) 

1532 assert not reply.jailed 

1533 

1534 

1535def test_unsuccessful_authenticate(db): 

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

1537 

1538 # Invalid password 

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

1540 with pytest.raises(grpc.RpcError) as e: 

1541 reply = auth_api.Authenticate(auth_pb2.AuthReq(user=user.username, password="incorrectpassword")) 

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

1543 assert e.value.details() == "Wrong username/email or password." 

1544 

1545 # Invalid username 

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

1547 with pytest.raises(grpc.RpcError) as e: 

1548 reply = auth_api.Authenticate(auth_pb2.AuthReq(user="notarealusername", password="password")) 

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

1550 assert e.value.details() == "An account with that username or email was not found." 

1551 

1552 # Invalid email 

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

1554 with pytest.raises(grpc.RpcError) as e: 

1555 reply = auth_api.Authenticate( 

1556 auth_pb2.AuthReq(user=f"{random_hex(12)}@couchers.org.invalid", password="password") 

1557 ) 

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

1559 assert e.value.details() == "An account with that username or email was not found." 

1560 

1561 # Invalid id 

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

1563 with pytest.raises(grpc.RpcError) as e: 

1564 reply = auth_api.Authenticate(auth_pb2.AuthReq(user="-1", password="password")) 

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

1566 assert e.value.details() == "An account with that username or email was not found." 

1567 

1568 

1569def test_complete_signup(db): 

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

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

1572 reply = auth_api.SignupFlow( 

1573 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="Tester", email=testing_email)) 

1574 ) 

1575 

1576 flow_token = reply.flow_token 

1577 

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

1579 # Invalid username 

1580 with pytest.raises(grpc.RpcError) as e: 

1581 auth_api.SignupFlow( 

1582 auth_pb2.SignupFlowReq( 

1583 flow_token=flow_token, 

1584 account=auth_pb2.SignupAccount( 

1585 username=" ", 

1586 password="a very insecure password", 

1587 city="Minas Tirith", 

1588 birthdate="1980-12-31", 

1589 gender="Robot", 

1590 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1591 lat=1, 

1592 lng=1, 

1593 radius=100, 

1594 accept_tos=True, 

1595 ), 

1596 ) 

1597 ) 

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

1599 assert e.value.details() == "Invalid username." 

1600 

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

1602 # Invalid name 

1603 with pytest.raises(grpc.RpcError) as e: 

1604 auth_api.SignupFlow( 

1605 auth_pb2.SignupFlowReq( 

1606 basic=auth_pb2.SignupBasic(name=" ", email=f"{random_hex(12)}@couchers.org.invalid") 

1607 ) 

1608 ) 

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

1610 assert e.value.details() == "Name not supported." 

1611 

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

1613 # Hosting status required 

1614 with pytest.raises(grpc.RpcError) as e: 

1615 auth_api.SignupFlow( 

1616 auth_pb2.SignupFlowReq( 

1617 flow_token=flow_token, 

1618 account=auth_pb2.SignupAccount( 

1619 username="frodo", 

1620 password="a very insecure password", 

1621 city="Minas Tirith", 

1622 birthdate="1980-12-31", 

1623 gender="Robot", 

1624 hosting_status=None, 

1625 lat=1, 

1626 lng=1, 

1627 radius=100, 

1628 accept_tos=True, 

1629 ), 

1630 ) 

1631 ) 

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

1633 assert e.value.details() == "Hosting status is required." 

1634 

1635 user, _ = generate_user() 

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

1637 # Username unavailable 

1638 with pytest.raises(grpc.RpcError) as e: 

1639 auth_api.SignupFlow( 

1640 auth_pb2.SignupFlowReq( 

1641 flow_token=flow_token, 

1642 account=auth_pb2.SignupAccount( 

1643 username=user.username, 

1644 password="a very insecure password", 

1645 city="Minas Tirith", 

1646 birthdate="1980-12-31", 

1647 gender="Robot", 

1648 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1649 lat=1, 

1650 lng=1, 

1651 radius=100, 

1652 accept_tos=True, 

1653 ), 

1654 ) 

1655 ) 

1656 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1657 assert e.value.details() == "Sorry, that username isn't available." 

1658 

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

1660 # Invalid coordinate 

1661 with pytest.raises(grpc.RpcError) as e: 

1662 auth_api.SignupFlow( 

1663 auth_pb2.SignupFlowReq( 

1664 flow_token=flow_token, 

1665 account=auth_pb2.SignupAccount( 

1666 username="frodo", 

1667 password="a very insecure password", 

1668 city="Minas Tirith", 

1669 birthdate="1980-12-31", 

1670 gender="Robot", 

1671 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1672 lat=0, 

1673 lng=0, 

1674 radius=100, 

1675 accept_tos=True, 

1676 ), 

1677 ) 

1678 ) 

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

1680 assert e.value.details() == "Invalid coordinate." 

1681 

1682 

1683def test_signup_token_regression(db): 

1684 # Repro steps: 

1685 # 1. Start a signup 

1686 # 2. Confirm the email 

1687 # 3. Start a new signup with the same email 

1688 # Expected: send a link to the email to continue signing up. 

1689 # Actual: `AttributeError: 'SignupFlow' object has no attribute 'token'` 

1690 

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

1692 

1693 # 1. Start a signup 

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

1695 res = auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email))) 

1696 flow_token = res.flow_token 

1697 assert flow_token 

1698 

1699 # 2. Confirm the email 

1700 with session_scope() as session: 

1701 email_token = session.execute( 

1702 select(SignupFlow.email_token).where(SignupFlow.flow_token == flow_token) 

1703 ).scalar_one() 

1704 

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

1706 auth_api.SignupFlow( 

1707 auth_pb2.SignupFlowReq( 

1708 flow_token=flow_token, 

1709 email_token=email_token, 

1710 ) 

1711 ) 

1712 

1713 # 3. Start a new signup with the same email 

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

1715 with pytest.raises(grpc.RpcError) as e: 

1716 auth_api.SignupFlow(auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="frodo", email=testing_email))) 

1717 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1718 assert e.value.details() == "Please check your email for a link to continue signing up." 

1719 

1720 

1721@pytest.mark.parametrize("opt_out", [True, False]) 

1722def test_opt_out_of_newsletter(db, opt_out): 

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

1724 res = auth_api.SignupFlow( 

1725 auth_pb2.SignupFlowReq( 

1726 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

1727 account=auth_pb2.SignupAccount( 

1728 username="frodo", 

1729 password="a very insecure password", 

1730 birthdate="1970-01-01", 

1731 gender="Bot", 

1732 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1733 city="New York City", 

1734 lat=40.7331, 

1735 lng=-73.9778, 

1736 radius=500, 

1737 accept_tos=True, 

1738 opt_out_of_newsletter=opt_out, 

1739 ), 

1740 feedback=auth_pb2.ContributorForm(), 

1741 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

1742 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

1743 ) 

1744 ) 

1745 

1746 with session_scope() as session: 

1747 email_token = session.execute( 

1748 select(SignupFlow.email_token).where(SignupFlow.flow_token == res.flow_token) 

1749 ).scalar_one() 

1750 

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

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

1753 

1754 user_id = res.auth_res.user_id 

1755 

1756 with session_scope() as session: 

1757 user = session.execute(select(User).where(User.id == user_id)).scalar_one() 

1758 assert not user.in_sync_with_newsletter 

1759 assert user.opt_out_of_newsletter == opt_out 

1760 

1761 

1762def test_GetAuthState(db): 

1763 user, token = generate_user() 

1764 jailed_user, jailed_token = generate_user(accepted_tos=0) 

1765 

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

1767 res = auth_api.GetAuthState(empty_pb2.Empty()) 

1768 assert not res.logged_in 

1769 assert not res.HasField("auth_res") 

1770 

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

1772 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),)) 

1773 assert res.logged_in 

1774 assert res.HasField("auth_res") 

1775 assert res.auth_res.user_id == user.id 

1776 assert not res.auth_res.jailed 

1777 

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

1779 

1780 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),)) 

1781 assert not res.logged_in 

1782 assert not res.HasField("auth_res") 

1783 

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

1785 res = auth_api.GetAuthState(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={jailed_token}"),)) 

1786 assert res.logged_in 

1787 assert res.HasField("auth_res") 

1788 assert res.auth_res.user_id == jailed_user.id 

1789 assert res.auth_res.jailed 

1790 

1791 

1792def test_signup_no_feedback_regression(db): 

1793 """ 

1794 When we first remove the feedback form, the backned was saying it's not needed but was not completing the signup, 

1795 this regression test checks that. 

1796 """ 

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

1798 res = auth_api.SignupFlow( 

1799 auth_pb2.SignupFlowReq( 

1800 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

1801 account=auth_pb2.SignupAccount( 

1802 username="frodo", 

1803 password="a very insecure password", 

1804 birthdate="1970-01-01", 

1805 gender="Bot", 

1806 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1807 city="New York City", 

1808 lat=40.7331, 

1809 lng=-73.9778, 

1810 radius=500, 

1811 accept_tos=True, 

1812 ), 

1813 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

1814 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

1815 ) 

1816 ) 

1817 

1818 flow_token = res.flow_token 

1819 

1820 assert res.flow_token 

1821 assert not res.HasField("auth_res") 

1822 assert not res.need_basic 

1823 assert not res.need_account 

1824 assert not res.need_feedback 

1825 assert not res.need_motivations 

1826 assert res.need_verify_email 

1827 

1828 # read out the signup token directly from the database for now 

1829 with session_scope() as session: 

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

1831 assert flow.email_sent 

1832 assert not flow.email_verified 

1833 email_token = flow.email_token 

1834 

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

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

1837 

1838 assert not res.flow_token 

1839 assert res.HasField("auth_res") 

1840 assert res.auth_res.user_id 

1841 assert not res.auth_res.jailed 

1842 assert not res.need_basic 

1843 assert not res.need_account 

1844 assert not res.need_feedback 

1845 assert not res.need_motivations 

1846 assert not res.need_verify_email 

1847 

1848 # make sure we got the right token in a cookie 

1849 with session_scope() as session: 

1850 token = session.execute( 

1851 select(UserSession.token).join(User, UserSession.user_id == User.id).where(User.username == "frodo") 

1852 ).scalar_one() 

1853 sesh, uid = get_session_cookie_tokens(metadata_interceptor) 

1854 assert sesh == token 

1855 

1856 

1857def test_banned_username(db): 

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

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

1860 reply = auth_api.SignupFlow( 

1861 auth_pb2.SignupFlowReq(basic=auth_pb2.SignupBasic(name="Tester", email=testing_email)) 

1862 ) 

1863 

1864 flow_token = reply.flow_token 

1865 

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

1867 # Banned username 

1868 with pytest.raises(grpc.RpcError) as e: 

1869 auth_api.SignupFlow( 

1870 auth_pb2.SignupFlowReq( 

1871 flow_token=flow_token, 

1872 account=auth_pb2.SignupAccount( 

1873 username="thecouchersadminaccount", 

1874 password="a very insecure password", 

1875 city="Minas Tirith", 

1876 birthdate="1980-12-31", 

1877 gender="Robot", 

1878 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1879 lat=1, 

1880 lng=1, 

1881 radius=100, 

1882 accept_tos=True, 

1883 ), 

1884 ) 

1885 ) 

1886 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1887 assert e.value.details() == "Sorry, that username isn't available." 

1888 

1889 

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

1891 

1892 

1893def test_GetInviteCodeInfo(db): 

1894 user, token = generate_user(complete_profile=True) 

1895 

1896 with account_session(token) as account: 

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

1898 

1899 with auth_api_session() as (auth, _): 

1900 res = auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code=code)) 

1901 assert res.name == user.name 

1902 assert res.username == user.username 

1903 # Avatar URL should be a thumbnail URL with a hashed filename 

1904 assert "/img/thumbnail/" in res.avatar_url 

1905 assert res.avatar_url.endswith(".jpg") 

1906 # Verify the hashed filename looks correct (64 char hex hash) 

1907 assert len(res.avatar_url.split("/")[-1].replace(".jpg", "")) == 64 

1908 assert res.url == urls.invite_code_link(code=code) 

1909 

1910 

1911def test_GetInviteCodeInfo_no_avatar(db): 

1912 user, token = generate_user(complete_profile=False) 

1913 

1914 with account_session(token) as account: 

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

1916 

1917 with auth_api_session() as (auth, _): 

1918 res = auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code=code)) 

1919 assert res.name == user.name 

1920 assert res.username == user.username 

1921 assert res.avatar_url == "" 

1922 assert res.url == urls.invite_code_link(code=code) 

1923 

1924 

1925def test_GetInviteCodeInfo_not_found(db): 

1926 generate_user() 

1927 

1928 with auth_api_session() as (auth, _): 

1929 with pytest.raises(grpc.RpcError) as e: 

1930 auth.GetInviteCodeInfo(auth_pb2.GetInviteCodeInfoReq(code="BADCODE")) 

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

1932 assert e.value.details() == "Invite code not found." 

1933 

1934 

1935def test_SignupFlow_invite_code(db): 

1936 user, token = generate_user() 

1937 

1938 with account_session(token) as account: 

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

1940 

1941 with auth_api_session() as (auth_api, _): 

1942 # Signup basic step with invite code 

1943 res = auth_api.SignupFlow( 

1944 auth_pb2.SignupFlowReq( 

1945 basic=auth_pb2.SignupBasic( 

1946 name="Test User", 

1947 email="inviteuser@example.com", 

1948 invite_code=invite_code, 

1949 ) 

1950 ) 

1951 ) 

1952 flow_token = res.flow_token 

1953 assert flow_token 

1954 

1955 # Confirm email 

1956 with session_scope() as session: 

1957 email_token = session.execute( 

1958 select(SignupFlow.email_token).where(SignupFlow.flow_token == flow_token) 

1959 ).scalar_one() 

1960 

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

1962 

1963 # Signup account step 

1964 auth_api.SignupFlow( 

1965 auth_pb2.SignupFlowReq( 

1966 flow_token=flow_token, 

1967 account=auth_pb2.SignupAccount( 

1968 username="invited_user", 

1969 password="secure password", 

1970 birthdate="1990-01-01", 

1971 gender="Other", 

1972 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

1973 city="Example City", 

1974 lat=1, 

1975 lng=5, 

1976 radius=100, 

1977 accept_tos=True, 

1978 ), 

1979 feedback=auth_pb2.ContributorForm(), 

1980 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

1981 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

1982 ) 

1983 ) 

1984 

1985 # Check that invite_code_id is stored in the final User object 

1986 with session_scope() as session: 

1987 invite_code_id = session.execute( 

1988 select(User.invite_code_id).where(User.username == "invited_user") 

1989 ).scalar_one() 

1990 assert invite_code_id == invite_code 

1991 

1992 

1993def test_signup_with_motivations(db): 

1994 """ 

1995 Test signup flow with the new motivations step (heard_about_couchers and signup_motivations) 

1996 """ 

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

1998 res = auth_api.SignupFlow( 

1999 auth_pb2.SignupFlowReq( 

2000 basic=auth_pb2.SignupBasic(name="testing", email="email@couchers.org.invalid"), 

2001 account=auth_pb2.SignupAccount( 

2002 username="intentuser", 

2003 password="a very insecure password", 

2004 birthdate="1970-01-01", 

2005 gender="Bot", 

2006 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

2007 city="New York City", 

2008 lat=40.7331, 

2009 lng=-73.9778, 

2010 radius=500, 

2011 accept_tos=True, 

2012 ), 

2013 motivations=auth_pb2.SignupMotivations( 

2014 heard_about_couchers="friend", 

2015 motivations=["hosting", "surfing", "events"], 

2016 ), 

2017 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

2018 ) 

2019 ) 

2020 

2021 flow_token = res.flow_token 

2022 assert flow_token 

2023 assert not res.HasField("auth_res") 

2024 assert res.need_verify_email 

2025 

2026 # Verify the motivations are stored in the SignupFlow 

2027 with session_scope() as session: 

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

2029 assert flow.heard_about_couchers == "friend" 

2030 assert set(flow.signup_motivations) == {"hosting", "surfing", "events"} 

2031 email_token = flow.email_token 

2032 

2033 # Complete signup by verifying email 

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

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

2036 

2037 assert res.HasField("auth_res") 

2038 user_id = res.auth_res.user_id 

2039 

2040 # Verify the motivations are transferred to the User object 

2041 with session_scope() as session: 

2042 user = session.execute(select(User).where(User.id == user_id)).scalar_one() 

2043 assert user.heard_about_couchers == "friend" 

2044 assert user.signup_motivations is not None 

2045 assert set(user.signup_motivations) == {"hosting", "surfing", "events"} 

2046 

2047 

2048def test_signup_motivations_incremental(db): 

2049 """ 

2050 Test that motivations can be submitted incrementally as a separate step 

2051 """ 

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

2053 # First, basic signup 

2054 res = auth_api.SignupFlow( 

2055 auth_pb2.SignupFlowReq( 

2056 basic=auth_pb2.SignupBasic(name="testing", email="email2@couchers.org.invalid"), 

2057 ) 

2058 ) 

2059 

2060 flow_token = res.flow_token 

2061 assert flow_token 

2062 assert res.need_account 

2063 assert res.need_motivations # New field 

2064 

2065 # Submit motivations separately 

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

2067 res = auth_api.SignupFlow( 

2068 auth_pb2.SignupFlowReq( 

2069 flow_token=flow_token, 

2070 motivations=auth_pb2.SignupMotivations( 

2071 heard_about_couchers="social_media", 

2072 motivations=["surfing"], 

2073 ), 

2074 ) 

2075 ) 

2076 

2077 assert res.flow_token == flow_token 

2078 assert not res.need_motivations # Should be filled now 

2079 assert res.need_account # Still need account 

2080 

2081 # Verify motivations are stored 

2082 with session_scope() as session: 

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

2084 assert flow.heard_about_couchers == "social_media" 

2085 assert flow.signup_motivations == ["surfing"] 

2086 

2087 

2088def test_signup_motivations_cannot_be_refilled(db): 

2089 """ 

2090 Test that motivations cannot be submitted twice 

2091 """ 

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

2093 res = auth_api.SignupFlow( 

2094 auth_pb2.SignupFlowReq( 

2095 basic=auth_pb2.SignupBasic(name="testing", email="email3@couchers.org.invalid"), 

2096 motivations=auth_pb2.SignupMotivations( 

2097 heard_about_couchers="friend", 

2098 motivations=["hosting"], 

2099 ), 

2100 ) 

2101 ) 

2102 

2103 flow_token = res.flow_token 

2104 

2105 # Try to submit motivations again - should fail 

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

2107 with pytest.raises(grpc.RpcError) as e: 

2108 auth_api.SignupFlow( 

2109 auth_pb2.SignupFlowReq( 

2110 flow_token=flow_token, 

2111 motivations=auth_pb2.SignupMotivations( 

2112 heard_about_couchers="different_source", 

2113 motivations=["surfing"], 

2114 ), 

2115 ) 

2116 ) 

2117 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

2118 assert e.value.details() == "You've already told us about why you are signing up." 

2119 

2120 

2121def test_signup_motivations_required(db): 

2122 """ 

2123 Test that signup cannot complete without providing motivations 

2124 """ 

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

2126 res = auth_api.SignupFlow( 

2127 auth_pb2.SignupFlowReq( 

2128 basic=auth_pb2.SignupBasic(name="testing", email="email4@couchers.org.invalid"), 

2129 account=auth_pb2.SignupAccount( 

2130 username="nointents", 

2131 password="a very insecure password", 

2132 birthdate="1970-01-01", 

2133 gender="Bot", 

2134 hosting_status=api_pb2.HOSTING_STATUS_CAN_HOST, 

2135 city="New York City", 

2136 lat=40.7331, 

2137 lng=-73.9778, 

2138 radius=500, 

2139 accept_tos=True, 

2140 ), 

2141 # No motivations provided 

2142 accept_community_guidelines=wrappers_pb2.BoolValue(value=True), 

2143 ) 

2144 ) 

2145 

2146 flow_token = res.flow_token 

2147 assert not res.HasField("auth_res") 

2148 assert res.need_motivations # Intents still required 

2149 

2150 with session_scope() as session: 

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

2152 email_token = flow.email_token 

2153 

2154 # Verify email - signup still not complete without motivations 

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

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

2157 

2158 assert not res.HasField("auth_res") 

2159 assert res.need_motivations 

2160 

2161 # Now submit motivations 

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

2163 res = auth_api.SignupFlow( 

2164 auth_pb2.SignupFlowReq( 

2165 flow_token=flow_token, 

2166 motivations=auth_pb2.SignupMotivations(motivations=["surfing"]), 

2167 ) 

2168 ) 

2169 

2170 assert res.HasField("auth_res") 

2171 user_id = res.auth_res.user_id 

2172 

2173 with session_scope() as session: 

2174 user = session.execute(select(User).where(User.id == user_id)).scalar_one() 

2175 assert user.signup_motivations == ["surfing"] 

2176 

2177 

2178def test_signup_motivations_all_options(db): 

2179 """ 

2180 Test all the different motivation options 

2181 """ 

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

2183 res = auth_api.SignupFlow( 

2184 auth_pb2.SignupFlowReq( 

2185 basic=auth_pb2.SignupBasic(name="testing", email="email5@couchers.org.invalid"), 

2186 motivations=auth_pb2.SignupMotivations( 

2187 heard_about_couchers="other", 

2188 motivations=["hosting", "surfing", "events"], 

2189 ), 

2190 ) 

2191 ) 

2192 

2193 flow_token = res.flow_token 

2194 

2195 with session_scope() as session: 

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

2197 assert flow.heard_about_couchers == "other" 

2198 assert set(flow.signup_motivations) == {"hosting", "surfing", "events"} 

2199 

2200 

2201def test_signup_motivations_empty_motivations_list(db): 

2202 """ 

2203 Test that providing heard_about but empty motivations list is valid 

2204 """ 

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

2206 res = auth_api.SignupFlow( 

2207 auth_pb2.SignupFlowReq( 

2208 basic=auth_pb2.SignupBasic(name="testing", email="email6@couchers.org.invalid"), 

2209 motivations=auth_pb2.SignupMotivations( 

2210 heard_about_couchers="former_cs_member", 

2211 motivations=[], # No specific motivations selected 

2212 ), 

2213 ) 

2214 ) 

2215 

2216 flow_token = res.flow_token 

2217 

2218 with session_scope() as session: 

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

2220 assert flow.heard_about_couchers == "former_cs_member" 

2221 assert flow.signup_motivations == [] 

2222 

2223 

2224def test_create_session_does_not_reselect_the_user(db): 

2225 """ 

2226 The commit in create_session expires the user, so anything read off it afterwards re-selects the whole row. 

2227 

2228 This is on the path of every login, so it was the most executed statement in the query log. 

2229 """ 

2230 user, _ = generate_user() 

2231 

2232 statements = [] 

2233 

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

2235 statements.append(statement) 

2236 

2237 engine = _get_base_engine() 

2238 with session_scope() as session: 

2239 db_user = session.execute(select(User).where(User.id == user.id)).scalar_one() 

2240 context = cast(CouchersContext, _MockCouchersContext()) 

2241 event.listen(engine, "before_cursor_execute", record) 

2242 try: 

2243 create_session(context, session, db_user, False) 

2244 finally: 

2245 event.remove(engine, "before_cursor_execute", record) 

2246 

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