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

1040 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-14 11:44 +0000

1import json 

2from datetime import UTC, date, datetime, timedelta 

3from unittest.mock import patch 

4 

5import grpc 

6import pytest 

7from sqlalchemy import select 

8from sqlalchemy.sql import func 

9 

10from couchers.db import session_scope 

11from couchers.models import ( 

12 AccountDeletionToken, 

13 ContentReport, 

14 EventOccurrence, 

15 FriendRelationship, 

16 FriendStatus, 

17 ModerationObjectType, 

18 ModerationState, 

19 ModerationUserList, 

20 ModerationVisibility, 

21 NonvisibleUserAccess, 

22 NonvisibleUserAccessType, 

23 NonvisibleUserState, 

24 Reference, 

25 Upload, 

26 User, 

27 UserActivity, 

28 UserSession, 

29) 

30from couchers.proto import ( 

31 account_pb2, 

32 admin_pb2, 

33 auth_pb2, 

34 events_pb2, 

35 references_pb2, 

36 reporting_pb2, 

37 requests_pb2, 

38) 

39from couchers.utils import Timestamp_from_datetime, now, parse_date 

40from tests.fixtures.db import add_users_to_new_moderation_list, generate_user, make_friends 

41from tests.fixtures.misc import EmailCollector, PushCollector 

42from tests.fixtures.sessions import ( 

43 account_session, 

44 auth_api_session, 

45 events_session, 

46 real_admin_session, 

47 references_session, 

48 reporting_session, 

49 requests_session, 

50) 

51from tests.test_communities import create_community 

52from tests.test_requests import valid_request_text 

53 

54 

55@pytest.fixture(autouse=True) 

56def _(testconfig): 

57 pass 

58 

59 

60def test_access_by_normal_user(db): 

61 normal_user, normal_token = generate_user() 

62 

63 with real_admin_session(normal_token) as api: 

64 # all requests to the admin servicer should break when done by a non-super_user 

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

66 api.GetUserDetails( 

67 admin_pb2.GetUserDetailsReq( 

68 user=str(normal_user.id), 

69 ) 

70 ) 

71 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

72 

73 

74def test_GetNonvisibleUserAccessLog(db): 

75 super_user, super_token = generate_user(is_superuser=True) 

76 target, _ = generate_user(username="target") 

77 viewer, _ = generate_user(username="viewer") 

78 

79 with session_scope() as session: 

80 session.add( 

81 NonvisibleUserAccess( 

82 access_type=NonvisibleUserAccessType.login_attempt, 

83 target_user_id=target.id, 

84 target_state=NonvisibleUserState.banned, 

85 actor_user_id=target.id, 

86 ip_address="1.2.3.4", 

87 sofa="device-cookie", 

88 ) 

89 ) 

90 session.add( 

91 NonvisibleUserAccess( 

92 access_type=NonvisibleUserAccessType.ghost_served, 

93 target_user_id=target.id, 

94 target_state=NonvisibleUserState.banned, 

95 actor_user_id=viewer.id, 

96 ) 

97 ) 

98 session.add( 

99 NonvisibleUserAccess( 

100 access_type=NonvisibleUserAccessType.ghost_served, 

101 target_user_id=target.id, 

102 target_state=NonvisibleUserState.banned, 

103 actor_user_id=None, 

104 ) 

105 ) 

106 

107 with real_admin_session(super_token) as api: 

108 res = api.GetNonvisibleUserAccessLog(admin_pb2.GetNonvisibleUserAccessLogReq(user="target")) 

109 

110 assert len(res.entries) == 3 

111 for entry in res.entries: 

112 assert entry.target_user_id == target.id 

113 assert entry.target_state == admin_pb2.NONVISIBLE_USER_STATE_BANNED 

114 

115 login = [e for e in res.entries if e.access_type == admin_pb2.NONVISIBLE_USER_ACCESS_TYPE_LOGIN_ATTEMPT] 

116 views = [e for e in res.entries if e.access_type == admin_pb2.NONVISIBLE_USER_ACCESS_TYPE_GHOST_SERVED] 

117 assert len(login) == 1 

118 assert len(views) == 2 

119 

120 assert login[0].actor_user_id.value == target.id 

121 assert login[0].actor_username == "target" 

122 assert login[0].ip_address == "1.2.3.4" 

123 assert login[0].sofa == "device-cookie" 

124 

125 logged_in_view = [e for e in views if e.actor_username == "viewer"] 

126 logged_out_view = [e for e in views if not e.actor_username] 

127 assert len(logged_in_view) == 1 

128 assert logged_in_view[0].actor_user_id.value == viewer.id 

129 assert len(logged_out_view) == 1 

130 assert not logged_out_view[0].HasField("actor_user_id") 

131 

132 

133def test_GetUser(db): 

134 super_user, super_token = generate_user(is_superuser=True) 

135 normal_user, normal_token = generate_user() 

136 

137 with real_admin_session(super_token) as api: 

138 res = api.GetUser(admin_pb2.GetUserReq(user=str(normal_user.id))) 

139 assert res.user_id == normal_user.id 

140 assert res.username == normal_user.username 

141 

142 with real_admin_session(super_token) as api: 

143 res = api.BanUser(admin_pb2.BanUserReq(user=normal_user.username, admin_note="Testing banning")) 

144 

145 with real_admin_session(super_token) as api: 

146 res = api.GetUser(admin_pb2.GetUserReq(user=str(normal_user.id))) 

147 assert res.user_id == normal_user.id 

148 assert res.username == normal_user.username 

149 

150 

151def test_GetUserDetails(db): 

152 super_user, super_token = generate_user(is_superuser=True) 

153 normal_user, normal_token = generate_user() 

154 

155 with real_admin_session(super_token) as api: 

156 res = api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=str(normal_user.id))) 

157 assert res.user_id == normal_user.id 

158 assert res.username == normal_user.username 

159 assert res.email == normal_user.email 

160 assert res.gender == normal_user.gender 

161 assert parse_date(res.birthdate) == normal_user.birthdate 

162 assert not res.banned 

163 assert not res.deleted 

164 

165 with real_admin_session(super_token) as api: 

166 res = api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.username)) 

167 assert res.user_id == normal_user.id 

168 assert res.username == normal_user.username 

169 assert res.email == normal_user.email 

170 assert res.gender == normal_user.gender 

171 assert parse_date(res.birthdate) == normal_user.birthdate 

172 assert not res.banned 

173 assert not res.deleted 

174 

175 with real_admin_session(super_token) as api: 

176 res = api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.email)) 

177 assert res.user_id == normal_user.id 

178 assert res.username == normal_user.username 

179 assert res.email == normal_user.email 

180 assert res.gender == normal_user.gender 

181 assert parse_date(res.birthdate) == normal_user.birthdate 

182 assert not res.banned 

183 assert not res.deleted 

184 

185 

186def test_ChangeUserGender(db, email_collector: EmailCollector, push_collector: PushCollector): 

187 super_user, super_token = generate_user(is_superuser=True) 

188 normal_user, normal_token = generate_user() 

189 

190 with real_admin_session(super_token) as api: 

191 res = api.ChangeUserGender(admin_pb2.ChangeUserGenderReq(user=normal_user.username, gender="Machine")) 

192 assert res.user_id == normal_user.id 

193 assert res.username == normal_user.username 

194 assert res.email == normal_user.email 

195 assert res.gender == "Machine" 

196 assert parse_date(res.birthdate) == normal_user.birthdate 

197 assert not res.banned 

198 assert not res.deleted 

199 

200 email = email_collector.pop_for_recipient(normal_user.email, last=True) 

201 assert email.subject == "[TEST] Your gender was changed" 

202 assert email.recipient == normal_user.email 

203 assert "Machine" in email.plain 

204 assert "Machine" in email.html 

205 

206 push = push_collector.pop_for_user(normal_user.id, last=True) 

207 assert push.content.title == "Gender changed" 

208 assert push.content.body == "An admin changed your gender to Machine." 

209 

210 

211def test_ChangeUserBirthdate(db, email_collector: EmailCollector, push_collector: PushCollector): 

212 super_user, super_token = generate_user(is_superuser=True) 

213 normal_user, normal_token = generate_user(birthdate=date(year=2000, month=1, day=1)) 

214 

215 with real_admin_session(super_token) as api: 

216 res = api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.username)) 

217 assert parse_date(res.birthdate) == date(year=2000, month=1, day=1) 

218 

219 res = api.ChangeUserBirthdate( 

220 admin_pb2.ChangeUserBirthdateReq(user=normal_user.username, birthdate="1990-05-25") 

221 ) 

222 

223 assert res.user_id == normal_user.id 

224 assert res.username == normal_user.username 

225 assert res.email == normal_user.email 

226 assert res.birthdate == "1990-05-25" 

227 assert res.gender == normal_user.gender 

228 assert not res.banned 

229 assert not res.deleted 

230 

231 email = email_collector.pop_for_recipient(normal_user.email, last=True) 

232 assert email.subject == "[TEST] Your date of birth was changed" 

233 assert email.recipient == normal_user.email 

234 assert "1990" in email.plain 

235 assert "1990" in email.html 

236 

237 push = push_collector.pop_for_user(normal_user.id, last=True) 

238 assert push.content.title == "Birthdate changed" 

239 assert push.content.body == "An admin changed your date of birth to May 25, 1990." 

240 

241 

242def test_BanUser(db): 

243 super_user, super_token = generate_user(is_superuser=True) 

244 normal_user, _ = generate_user() 

245 admin_note = "A good reason" 

246 

247 with real_admin_session(super_token) as api: 

248 res = api.BanUser(admin_pb2.BanUserReq(user=normal_user.username, admin_note=admin_note)) 

249 assert res.user_id == normal_user.id 

250 assert res.username == normal_user.username 

251 assert res.email == normal_user.email 

252 assert res.gender == normal_user.gender 

253 assert parse_date(res.birthdate) == normal_user.birthdate 

254 assert res.banned 

255 assert not res.deleted 

256 assert len(res.admin_actions) == 1 

257 assert res.admin_actions[0].action_type == "ban" 

258 assert res.admin_actions[0].level == admin_pb2.ADMIN_ACTION_LEVEL_HIGH 

259 assert res.admin_actions[0].note == admin_note 

260 assert res.admin_actions[0].admin_user_id == super_user.id 

261 assert res.admin_actions[0].admin_username == super_user.username 

262 

263 

264def test_UnbanUser(db): 

265 super_user, super_token = generate_user(is_superuser=True) 

266 normal_user, _ = generate_user() 

267 admin_note = "A good reason" 

268 

269 with real_admin_session(super_token) as api: 

270 res = api.UnbanUser(admin_pb2.UnbanUserReq(user=normal_user.username, admin_note=admin_note)) 

271 assert res.user_id == normal_user.id 

272 assert res.username == normal_user.username 

273 assert res.email == normal_user.email 

274 assert res.gender == normal_user.gender 

275 assert parse_date(res.birthdate) == normal_user.birthdate 

276 assert not res.banned 

277 assert not res.deleted 

278 assert len(res.admin_actions) == 1 

279 assert res.admin_actions[0].action_type == "unban" 

280 assert res.admin_actions[0].level == admin_pb2.ADMIN_ACTION_LEVEL_HIGH 

281 

282 

283def test_ShadowUser(db): 

284 super_user, super_token = generate_user(is_superuser=True) 

285 surfer, surfer_token = generate_user() 

286 host, _ = generate_user() 

287 admin_note = "Spammer" 

288 

289 # Create a host request from `surfer` and approve its moderation state to VISIBLE so we can verify the cascade 

290 today_plus_2 = (date.today() + timedelta(days=2)).isoformat() 

291 today_plus_3 = (date.today() + timedelta(days=3)).isoformat() 

292 with requests_session(surfer_token) as api: 

293 host_request_id = api.CreateHostRequest( 

294 requests_pb2.CreateHostRequestReq( 

295 host_user_id=host.id, 

296 from_date=today_plus_2, 

297 to_date=today_plus_3, 

298 text=valid_request_text(), 

299 ) 

300 ).host_request_id 

301 with session_scope() as session: 

302 state = session.execute( 

303 select(ModerationState) 

304 .where(ModerationState.object_type == ModerationObjectType.host_request) 

305 .where(ModerationState.object_id == host_request_id) 

306 ).scalar_one() 

307 state.visibility = ModerationVisibility.visible 

308 

309 with real_admin_session(super_token) as api: 

310 res = api.ShadowUser(admin_pb2.ShadowUserReq(user=surfer.username, admin_note=admin_note)) 

311 assert res.user_id == surfer.id 

312 assert res.shadowed 

313 assert not res.banned 

314 assert not res.deleted 

315 assert len(res.admin_actions) == 1 

316 assert res.admin_actions[0].action_type == "shadow" 

317 assert res.admin_actions[0].level == admin_pb2.ADMIN_ACTION_LEVEL_HIGH 

318 assert res.admin_actions[0].note == admin_note 

319 

320 # The previously-visible host request is now shadowed 

321 with session_scope() as session: 

322 state = session.execute( 

323 select(ModerationState) 

324 .where(ModerationState.object_type == ModerationObjectType.host_request) 

325 .where(ModerationState.object_id == host_request_id) 

326 ).scalar_one() 

327 assert state.visibility == ModerationVisibility.shadowed 

328 

329 

330def test_UnshadowUser(db): 

331 super_user, super_token = generate_user(is_superuser=True) 

332 surfer, surfer_token = generate_user() 

333 host, _ = generate_user() 

334 

335 today_plus_2 = (date.today() + timedelta(days=2)).isoformat() 

336 today_plus_3 = (date.today() + timedelta(days=3)).isoformat() 

337 with requests_session(surfer_token) as api: 

338 shadow_cascade_request_id = api.CreateHostRequest( 

339 requests_pb2.CreateHostRequestReq( 

340 host_user_id=host.id, 

341 from_date=today_plus_2, 

342 to_date=today_plus_3, 

343 text=valid_request_text(), 

344 ) 

345 ).host_request_id 

346 admin_hidden_request_id = api.CreateHostRequest( 

347 requests_pb2.CreateHostRequestReq( 

348 host_user_id=host.id, 

349 from_date=today_plus_2, 

350 to_date=today_plus_3, 

351 text=valid_request_text(), 

352 ) 

353 ).host_request_id 

354 

355 with session_scope() as session: 

356 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now() 

357 session.execute( 

358 select(ModerationState) 

359 .where(ModerationState.object_type == ModerationObjectType.host_request) 

360 .where(ModerationState.object_id == shadow_cascade_request_id) 

361 ).scalar_one().visibility = ModerationVisibility.shadowed 

362 session.execute( 

363 select(ModerationState) 

364 .where(ModerationState.object_type == ModerationObjectType.host_request) 

365 .where(ModerationState.object_id == admin_hidden_request_id) 

366 ).scalar_one().visibility = ModerationVisibility.hidden 

367 

368 with real_admin_session(super_token) as api: 

369 res = api.UnshadowUser(admin_pb2.UnshadowUserReq(user=surfer.username, admin_note="rehabilitated")) 

370 assert not res.shadowed 

371 assert len(res.admin_actions) == 1 

372 assert res.admin_actions[0].action_type == "unshadow" 

373 assert res.admin_actions[0].level == admin_pb2.ADMIN_ACTION_LEVEL_HIGH 

374 

375 with session_scope() as session: 

376 assert ( 

377 session.execute( 

378 select(ModerationState) 

379 .where(ModerationState.object_type == ModerationObjectType.host_request) 

380 .where(ModerationState.object_id == shadow_cascade_request_id) 

381 ) 

382 .scalar_one() 

383 .visibility 

384 == ModerationVisibility.visible 

385 ) 

386 assert ( 

387 session.execute( 

388 select(ModerationState) 

389 .where(ModerationState.object_type == ModerationObjectType.host_request) 

390 .where(ModerationState.object_id == admin_hidden_request_id) 

391 ) 

392 .scalar_one() 

393 .visibility 

394 == ModerationVisibility.hidden 

395 ) 

396 

397 

398def test_ShadowUser_blank_note(db): 

399 super_user, super_token = generate_user(is_superuser=True) 

400 normal_user, _ = generate_user() 

401 

402 with real_admin_session(super_token) as api: 

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

404 api.ShadowUser(admin_pb2.ShadowUserReq(user=normal_user.username, admin_note=" \t ")) 

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

406 

407 

408def test_AddAdminNote(db): 

409 super_user, super_token = generate_user(is_superuser=True) 

410 normal_user, _ = generate_user() 

411 admin_note1 = "User reported strange behavior" 

412 admin_note2 = "Insert private information here" 

413 

414 with real_admin_session(super_token) as api: 

415 res = api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username, admin_note=admin_note1)) 

416 assert res.user_id == normal_user.id 

417 assert res.username == normal_user.username 

418 assert res.email == normal_user.email 

419 assert res.gender == normal_user.gender 

420 assert parse_date(res.birthdate) == normal_user.birthdate 

421 assert not res.banned 

422 assert not res.deleted 

423 assert len(res.admin_actions) == 1 

424 assert res.admin_actions[0].action_type == "note" 

425 assert res.admin_actions[0].level == admin_pb2.ADMIN_ACTION_LEVEL_NORMAL 

426 assert res.admin_actions[0].note == admin_note1 

427 

428 with real_admin_session(super_token) as api: 

429 res = api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username, admin_note=admin_note2)) 

430 assert len(res.admin_actions) == 2 

431 assert res.admin_actions[0].note == admin_note1 

432 assert res.admin_actions[1].note == admin_note2 

433 

434 

435def test_AddAdminNote_blank(db): 

436 super_user, super_token = generate_user(is_superuser=True) 

437 normal_user, _ = generate_user() 

438 empty_admin_note = " \t \n " 

439 

440 with real_admin_session(super_token) as api: 

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

442 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username, admin_note=empty_admin_note)) 

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

444 assert e.value.details() == "Provide exactly one of admin_note or data." 

445 

446 

447def test_AddAdminNote_data(db): 

448 super_user, super_token = generate_user(is_superuser=True) 

449 normal_user, _ = generate_user() 

450 payload = '{"kind": "flag", "score": 0.87, "reasons": ["spam", "burst"]}' 

451 

452 with real_admin_session(super_token) as api: 

453 res = api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username, data=payload)) 

454 assert len(res.admin_actions) == 1 

455 assert res.admin_actions[0].action_type == "note" 

456 assert res.admin_actions[0].note == "" 

457 assert json.loads(res.admin_actions[0].data) == {"kind": "flag", "score": 0.87, "reasons": ["spam", "burst"]} 

458 

459 

460def test_AddAdminNote_both_note_and_data(db): 

461 super_user, super_token = generate_user(is_superuser=True) 

462 normal_user, _ = generate_user() 

463 

464 with real_admin_session(super_token) as api: 

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

466 api.AddAdminNote( 

467 admin_pb2.AddAdminNoteReq(user=normal_user.username, admin_note="note text", data='{"x": 1}') 

468 ) 

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

470 assert e.value.details() == "Provide exactly one of admin_note or data." 

471 

472 

473def test_AddAdminNote_neither(db): 

474 super_user, super_token = generate_user(is_superuser=True) 

475 normal_user, _ = generate_user() 

476 

477 with real_admin_session(super_token) as api: 

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

479 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username)) 

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

481 assert e.value.details() == "Provide exactly one of admin_note or data." 

482 

483 

484def test_AddAdminNote_invalid_json(db): 

485 super_user, super_token = generate_user(is_superuser=True) 

486 normal_user, _ = generate_user() 

487 

488 with real_admin_session(super_token) as api: 

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

490 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username, data="{not valid json")) 

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

492 assert e.value.details() == "The admin note data must be valid JSON." 

493 

494 

495def test_admin_content_reports(db): 

496 super_user, super_token = generate_user(is_superuser=True) 

497 normal_user, token = generate_user() 

498 bad_user1, _ = generate_user() 

499 bad_user2, _ = generate_user() 

500 

501 with reporting_session(token) as api: 

502 api.Report( 

503 reporting_pb2.ReportReq( 

504 reason="spam", 

505 description="r1", 

506 content_ref="comment/123", 

507 author_user=bad_user1.username, 

508 user_agent="n/a", 

509 page="https://couchers.org/comment/123", 

510 ) 

511 ) 

512 api.Report( 

513 reporting_pb2.ReportReq( 

514 reason="spam", 

515 description="r2", 

516 content_ref="comment/124", 

517 author_user=bad_user2.username, 

518 user_agent="n/a", 

519 page="https://couchers.org/comment/124", 

520 ) 

521 ) 

522 api.Report( 

523 reporting_pb2.ReportReq( 

524 reason="something else", 

525 description="r3", 

526 content_ref="page/321", 

527 author_user=bad_user1.username, 

528 user_agent="n/a", 

529 page="https://couchers.org/page/321", 

530 ) 

531 ) 

532 

533 with session_scope() as session: 

534 id_by_description: dict[str, int] = dict( 

535 session.execute(select(ContentReport.description, ContentReport.id)).all() # type: ignore[arg-type] 

536 ) 

537 

538 with real_admin_session(super_token) as api: 

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

540 api.GetContentReport(admin_pb2.GetContentReportReq(content_report_id=-1)) 

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

542 assert e.value.details() == "Content report not found." 

543 

544 res = api.GetContentReport(admin_pb2.GetContentReportReq(content_report_id=id_by_description["r2"])) 

545 rep = res.content_report 

546 assert rep.content_report_id == id_by_description["r2"] 

547 assert rep.reporting_user_id == normal_user.id 

548 assert rep.author_user_id == bad_user2.id 

549 assert rep.reason == "spam" 

550 assert rep.description == "r2" 

551 assert rep.content_ref == "comment/124" 

552 assert rep.user_agent == "n/a" 

553 assert rep.page == "https://couchers.org/comment/124" 

554 

555 res = api.GetContentReportsForAuthor(admin_pb2.GetContentReportsForAuthorReq(user=bad_user1.username)) 

556 assert res.content_reports[0].content_report_id == id_by_description["r3"] 

557 assert res.content_reports[1].content_report_id == id_by_description["r1"] 

558 

559 

560def test_DeleteUser(db): 

561 super_user, super_token = generate_user(is_superuser=True) 

562 normal_user, normal_token = generate_user() 

563 

564 with real_admin_session(super_token) as api: 

565 res = api.DeleteUser(admin_pb2.DeleteUserReq(user=normal_user.username)) 

566 assert res.user_id == normal_user.id 

567 assert res.username == normal_user.username 

568 assert res.email == normal_user.email 

569 assert res.gender == normal_user.gender 

570 assert parse_date(res.birthdate) == normal_user.birthdate 

571 assert not res.banned 

572 assert res.deleted 

573 

574 with real_admin_session(super_token) as api: 

575 res = api.RecoverDeletedUser(admin_pb2.RecoverDeletedUserReq(user=normal_user.username)) 

576 assert res.user_id == normal_user.id 

577 assert res.username == normal_user.username 

578 assert res.email == normal_user.email 

579 assert res.gender == normal_user.gender 

580 assert parse_date(res.birthdate) == normal_user.birthdate 

581 assert not res.banned 

582 assert not res.deleted 

583 

584 

585def test_RecoverDeletedUser_after_user_initiated_deletion(db, push_collector: PushCollector): 

586 """ 

587 When a user deletes their account through the normal flow (ConfirmDeleteAccount), 

588 undelete_token and undelete_until are set. The admin RecoverDeletedUser must clear 

589 these fields to satisfy the undelete_nullity database constraint. 

590 """ 

591 super_user, super_token = generate_user(is_superuser=True) 

592 normal_user, normal_token = generate_user() 

593 user_id = normal_user.id 

594 

595 # User initiates account deletion 

596 with account_session(normal_token) as account: 

597 account.DeleteAccount(account_pb2.DeleteAccountReq(confirm=True)) 

598 

599 # Get the deletion confirmation token 

600 with session_scope() as session: 

601 deletion_token = session.execute(select(AccountDeletionToken)).scalar_one().token 

602 

603 # User confirms account deletion (this sets undelete_token and undelete_until) 

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

605 auth_api.ConfirmDeleteAccount(auth_pb2.ConfirmDeleteAccountReq(token=deletion_token)) 

606 

607 # Verify the user is deleted and has undelete fields set 

608 with session_scope() as session: 

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

610 assert user.deleted_at is not None 

611 assert user.undelete_token is not None 

612 assert user.undelete_until is not None 

613 

614 # Admin recovers the user 

615 with real_admin_session(super_token) as api: 

616 res = api.RecoverDeletedUser(admin_pb2.RecoverDeletedUserReq(user=normal_user.username)) 

617 assert res.user_id == user_id 

618 assert not res.deleted 

619 

620 # Verify undelete fields are cleared 

621 with session_scope() as session: 

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

623 assert user.deleted_at is None 

624 assert user.undelete_token is None 

625 assert user.undelete_until is None 

626 

627 

628def test_CreateApiKey(db, email_collector: EmailCollector, push_collector: PushCollector): 

629 with session_scope() as session: 

630 super_user, super_token = generate_user(is_superuser=True) 

631 normal_user, normal_token = generate_user() 

632 

633 assert ( 

634 session.execute( 

635 select(func.count()) 

636 .select_from(UserSession) 

637 .where(UserSession.is_api_key == True) 

638 .where(UserSession.user_id == normal_user.id) 

639 ).scalar_one() 

640 == 0 

641 ) 

642 

643 with real_admin_session(super_token) as api: 

644 res = api.CreateApiKey(admin_pb2.CreateApiKeyReq(user=normal_user.username)) 

645 

646 email = email_collector.pop_for_recipient(normal_user.email, last=True) 

647 assert email.subject == "[TEST] Your API key for Couchers.org" 

648 

649 with session_scope() as session: 

650 token = session.execute( 

651 select(UserSession.token) 

652 .where(UserSession.is_valid) 

653 .where(UserSession.is_api_key == True) 

654 .where(UserSession.user_id == normal_user.id) 

655 ).scalar_one() 

656 

657 assert token in email.plain 

658 assert token in email.html 

659 

660 assert email.recipient == normal_user.email 

661 assert "api key" in email.subject.lower() 

662 unique_string = "We've issued you with the following API key:" 

663 assert unique_string in email.plain 

664 assert unique_string in email.html 

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

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

667 

668 push = push_collector.pop_for_user(normal_user.id, last=True) 

669 assert push.content.title == "API key created" 

670 assert push.content.body == "Details were sent to you via email." 

671 

672 

673def test_GetChats(db): 

674 super_user, super_token = generate_user(is_superuser=True) 

675 normal_user, normal_token = generate_user() 

676 

677 with real_admin_session(super_token) as api: 

678 res = api.GetChats(admin_pb2.GetChatsReq(user=normal_user.username)) 

679 assert res.user.user_id == normal_user.id 

680 assert res.user.username == normal_user.username 

681 assert res.user.name == normal_user.name 

682 # New user should have no chats 

683 assert len(res.host_requests) == 0 

684 assert len(res.group_chats) == 0 

685 

686 

687def test_badges(db, email_collector: EmailCollector, push_collector: PushCollector): 

688 super_user, super_token = generate_user(is_superuser=True) 

689 normal_user, normal_token = generate_user() 

690 

691 with real_admin_session(super_token) as api: 

692 # can add a badge 

693 assert "swagster" not in api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.username)).badges 

694 res = api.AddBadge(admin_pb2.AddBadgeReq(user=normal_user.username, badge_id="swagster")) 

695 assert "swagster" in res.badges 

696 

697 # badge emails are disabled by default 

698 assert email_collector.count_for_recipient(normal_user.email) == 0 

699 

700 push = push_collector.pop_for_user(normal_user.id, last=True) 

701 assert push.content.title == "New profile badge: Swagster" 

702 assert push.content.body == "The Swagster badge was added to your profile." 

703 

704 # can't add/edit special tags 

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

706 api.AddBadge(admin_pb2.AddBadgeReq(user=normal_user.username, badge_id="founder")) 

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

708 assert e.value.details() == "Admins cannot edit that badge." 

709 

710 # double add badge 

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

712 api.AddBadge(admin_pb2.AddBadgeReq(user=normal_user.username, badge_id="swagster")) 

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

714 assert e.value.details() == "The user already has that badge." 

715 

716 # can remove badge 

717 assert "swagster" in api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.username)).badges 

718 res = api.RemoveBadge(admin_pb2.RemoveBadgeReq(user=normal_user.username, badge_id="swagster")) 

719 assert "swagster" not in res.badges 

720 

721 # badge emails are disabled by default 

722 assert email_collector.count_for_recipient(normal_user.email) == 0 

723 

724 push = push_collector.pop_for_user(normal_user.id, last=True) 

725 assert push.content.title == "Profile badge removed" 

726 assert push.content.body == "The Swagster badge was removed from your profile." 

727 

728 # not found on user 

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

730 api.RemoveBadge(admin_pb2.RemoveBadgeReq(user=normal_user.username, badge_id="swagster")) 

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

732 assert e.value.details() == "The user does not have that badge." 

733 

734 # not found in general 

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

736 api.AddBadge(admin_pb2.AddBadgeReq(user=normal_user.username, badge_id="nonexistentbadge")) 

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

738 assert e.value.details() == "Badge not found." 

739 

740 

741def test_DeleteEvent(db): 

742 super_user, super_token = generate_user(is_superuser=True) 

743 normal_user, normal_token = generate_user() 

744 

745 with session_scope() as session: 

746 create_community(session, 0, 2, "Community", [normal_user], [], None) 

747 

748 start_time = now() + timedelta(hours=2) 

749 end_time = start_time + timedelta(hours=3) 

750 with events_session(normal_token) as api: 

751 res = api.CreateEvent( 

752 events_pb2.CreateEventReq( 

753 title="Dummy Title", 

754 content="Dummy content.", 

755 photo_key=None, 

756 offline_information=events_pb2.OfflineEventInformation( 

757 address="Near Null Island", 

758 lat=0.1, 

759 lng=0.2, 

760 ), 

761 start_time=Timestamp_from_datetime(start_time), 

762 end_time=Timestamp_from_datetime(end_time), 

763 timezone="UTC", 

764 ) 

765 ) 

766 event_id = res.event_id 

767 assert not res.is_deleted 

768 

769 with session_scope() as session: 

770 with real_admin_session(super_token) as api: 

771 api.DeleteEvent( 

772 admin_pb2.DeleteEventReq( 

773 event_id=event_id, 

774 ) 

775 ) 

776 occurrence = session.get_one(EventOccurrence, ident=event_id) 

777 assert occurrence.is_deleted 

778 

779 

780def test_ListUserIds(db): 

781 super_user, super_token = generate_user(is_superuser=True) 

782 normal_user, normal_token = generate_user() 

783 

784 with real_admin_session(super_token) as api: 

785 res = api.ListUserIds( 

786 admin_pb2.ListUserIdsReq( 

787 start_time=Timestamp_from_datetime(datetime(2000, 1, 1, tzinfo=UTC)), 

788 end_time=Timestamp_from_datetime(now()), 

789 ) 

790 ) 

791 assert len(res.user_ids) == 2 

792 assert sorted(res.user_ids) == sorted([super_user.id, normal_user.id]) 

793 

794 with real_admin_session(super_token) as api: 

795 res = api.ListUserIds( 

796 admin_pb2.ListUserIdsReq(start_time=Timestamp_from_datetime(now()), end_time=Timestamp_from_datetime(now())) 

797 ) 

798 assert res.user_ids == [] 

799 

800 

801def test_EditReferenceText(db): 

802 super_user, super_token = generate_user(is_superuser=True) 

803 test_new_text = "New Text" 

804 

805 user1, user1_token = generate_user() 

806 user2, user2_token = generate_user() 

807 make_friends(user1, user2) 

808 

809 with session_scope() as session: 

810 with references_session(user1_token) as api: 

811 reference = api.WriteFriendReference( 

812 references_pb2.WriteFriendReferenceReq( 

813 to_user_id=user2.id, text="Old Text", private_text="", was_appropriate=True, rating=1 

814 ) 

815 ) 

816 

817 with real_admin_session(super_token) as admin_api: 

818 admin_api.EditReferenceText( 

819 admin_pb2.EditReferenceTextReq(reference_id=reference.reference_id, new_text=test_new_text) 

820 ) 

821 

822 session.expire_all() 

823 

824 modified_reference = session.execute( 

825 select(Reference).where(Reference.id == reference.reference_id) 

826 ).scalar_one() 

827 assert modified_reference.text == test_new_text 

828 

829 

830def test_DeleteReference_deprecated(db): 

831 """DeleteReference is deprecated; admins should hide via UMS instead.""" 

832 super_user, super_token = generate_user(is_superuser=True) 

833 

834 user1, user1_token = generate_user() 

835 user2, user2_token = generate_user() 

836 make_friends(user1, user2) 

837 

838 with references_session(user1_token) as api: 

839 reference = api.WriteFriendReference( 

840 references_pb2.WriteFriendReferenceReq( 

841 to_user_id=user2.id, text="Old Text", private_text="", was_appropriate=True, rating=1 

842 ) 

843 ) 

844 

845 with real_admin_session(super_token) as admin_api: 

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

847 admin_api.DeleteReference(admin_pb2.DeleteReferenceReq(reference_id=reference.reference_id)) 

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

849 

850 

851def test_GetUserReferences(db): 

852 super_user, super_token = generate_user(is_superuser=True) 

853 

854 user1, user1_token = generate_user() 

855 user2, user2_token = generate_user() 

856 user3, user3_token = generate_user() 

857 make_friends(user1, user2) 

858 make_friends(user1, user3) 

859 make_friends(user2, user3) 

860 

861 # user1 writes reference about user2 

862 with references_session(user1_token) as api: 

863 ref1 = api.WriteFriendReference( 

864 references_pb2.WriteFriendReferenceReq( 

865 to_user_id=user2.id, 

866 text="Reference from user1 to user2", 

867 private_text="", 

868 was_appropriate=True, 

869 rating=1, 

870 ) 

871 ) 

872 

873 # user2 writes reference about user1 

874 with references_session(user2_token) as api: 

875 ref2 = api.WriteFriendReference( 

876 references_pb2.WriteFriendReferenceReq( 

877 to_user_id=user1.id, 

878 text="Reference from user2 to user1", 

879 private_text="Private note", 

880 was_appropriate=True, 

881 rating=0.8, 

882 ) 

883 ) 

884 

885 # user3 writes reference about user1 

886 with references_session(user3_token) as api: 

887 ref3 = api.WriteFriendReference( 

888 references_pb2.WriteFriendReferenceReq( 

889 to_user_id=user1.id, 

890 text="Reference from user3 to user1", 

891 private_text="", 

892 was_appropriate=False, 

893 rating=0.5, 

894 ) 

895 ) 

896 

897 # Test GetUserReferences for user1 (admin view shows everything regardless of UMS state). 

898 with real_admin_session(super_token) as admin_api: 

899 res = admin_api.GetUserReferences(admin_pb2.GetUserReferencesReq(user=user1.username)) 

900 

901 # user1 wrote 1 reference 

902 assert len(res.references_from) == 1 

903 assert res.references_from[0].reference_id == ref1.reference_id 

904 assert res.references_from[0].from_user_id == user1.id 

905 assert res.references_from[0].to_user_id == user2.id 

906 assert res.references_from[0].text == "Reference from user1 to user2" 

907 

908 # user1 received 2 references 

909 assert len(res.references_to) == 2 

910 # Ordered by id descending, so ref3 comes first 

911 assert res.references_to[0].reference_id == ref3.reference_id 

912 assert res.references_to[0].was_appropriate is False 

913 

914 assert res.references_to[1].reference_id == ref2.reference_id 

915 assert res.references_to[1].private_text == "Private note" 

916 assert res.references_to[1].rating == 0.8 

917 

918 

919def test_GetUserReferences_not_found(db): 

920 super_user, super_token = generate_user(is_superuser=True) 

921 

922 with real_admin_session(super_token) as admin_api: 

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

924 admin_api.GetUserReferences(admin_pb2.GetUserReferencesReq(user="nonexistent")) 

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

926 

927 

928def test_GetFriendRequests(db): 

929 super_user, super_token = generate_user(is_superuser=True) 

930 

931 user1, _ = generate_user() 

932 user2, _ = generate_user() 

933 user3, _ = generate_user() 

934 user4, _ = generate_user() 

935 

936 # Create a mix of friend requests directly so we control the state 

937 def _add_friend_request(from_user_id, to_user_id, status, visibility, time_responded=None): 

938 with session_scope() as session: 

939 mod_state = ModerationState( 

940 object_type=ModerationObjectType.friend_request, 

941 object_id=0, 

942 visibility=visibility, 

943 ) 

944 session.add(mod_state) 

945 session.flush() 

946 rel = FriendRelationship( 

947 from_user_id=from_user_id, 

948 to_user_id=to_user_id, 

949 status=status, 

950 moderation_state_id=mod_state.id, 

951 time_responded=time_responded, 

952 ) 

953 session.add(rel) 

954 session.flush() 

955 mod_state.object_id = rel.id 

956 

957 # user1 -> user2: pending, shadowed 

958 _add_friend_request(user1.id, user2.id, FriendStatus.pending, ModerationVisibility.shadowed) 

959 # user1 -> user3: accepted, visible 

960 _add_friend_request(user1.id, user3.id, FriendStatus.accepted, ModerationVisibility.visible, time_responded=now()) 

961 # user4 -> user1: rejected, visible 

962 _add_friend_request(user4.id, user1.id, FriendStatus.rejected, ModerationVisibility.visible, time_responded=now()) 

963 

964 with real_admin_session(super_token) as admin_api: 

965 res = admin_api.GetFriendRequests(admin_pb2.GetFriendRequestsReq(user=user1.username)) 

966 

967 # user1 sent two: to user2 (pending) and to user3 (accepted), ordered by id desc 

968 assert len(res.sent) == 2 

969 assert res.sent[0].from_user.user_id == user1.id 

970 assert res.sent[0].to_user.user_id == user3.id 

971 assert res.sent[0].status == "accepted" 

972 assert res.sent[0].HasField("time_responded") 

973 assert res.sent[0].moderation_visibility == "visible" 

974 

975 assert res.sent[1].from_user.user_id == user1.id 

976 assert res.sent[1].to_user.user_id == user2.id 

977 assert res.sent[1].status == "pending" 

978 assert not res.sent[1].HasField("time_responded") 

979 assert res.sent[1].moderation_visibility == "shadowed" 

980 

981 # user1 received one: from user4 (rejected) 

982 assert len(res.received) == 1 

983 assert res.received[0].from_user.user_id == user4.id 

984 assert res.received[0].to_user.user_id == user1.id 

985 assert res.received[0].status == "rejected" 

986 

987 

988def test_GetFriendRequests_not_found(db): 

989 super_user, super_token = generate_user(is_superuser=True) 

990 

991 with real_admin_session(super_token) as admin_api: 

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

993 admin_api.GetFriendRequests(admin_pb2.GetFriendRequestsReq(user="nonexistent")) 

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

995 

996 

997def test_AddUsersToModerationUserList(db): 

998 super_user, super_token = generate_user(is_superuser=True) 

999 user1, _ = generate_user() 

1000 user2, _ = generate_user() 

1001 user3, _ = generate_user() 

1002 user4, _ = generate_user() 

1003 user5, _ = generate_user() 

1004 moderation_list_id = add_users_to_new_moderation_list([user1]) 

1005 

1006 with session_scope() as session: 

1007 with real_admin_session(super_token) as api: 

1008 # Test adding users to a non-existent moderation list (should raise an error) 

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

1010 api.AddUsersToModerationUserList( 

1011 admin_pb2.AddUsersToModerationUserListReq(users=[user2.username], moderation_list_id=999), 

1012 ) 

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

1014 assert "Moderation user list not found." == e.value.details() 

1015 

1016 # Test with non-existent user (should raise an error) 

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

1018 api.AddUsersToModerationUserList( 

1019 admin_pb2.AddUsersToModerationUserListReq(users=[user1.username, "nonexistent"]), 

1020 ) 

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

1022 assert "Couldn't find that user." == e.value.details() 

1023 

1024 # Test successful creation of new moderation list (no moderation_list_id provided) 

1025 res = api.AddUsersToModerationUserList( 

1026 admin_pb2.AddUsersToModerationUserListReq(users=[user1.username, user2.username, user3.username]), 

1027 ) 

1028 assert res.moderation_list_id > 0 

1029 with session_scope() as session: 

1030 moderation_user_list = session.get(ModerationUserList, res.moderation_list_id) 

1031 assert moderation_user_list is not None 

1032 assert len(moderation_user_list.users) == 3 

1033 assert {user1.id, user2.id, user3.id}.issubset({user.id for user in moderation_user_list.users}) 

1034 

1035 # Test list endpoint returns same moderation list with same members not repeated 

1036 listRes = api.ListModerationUserLists(admin_pb2.ListModerationUserListsReq(user=user2.username)) 

1037 assert len(listRes.moderation_lists) == 1 

1038 assert listRes.moderation_lists[0].moderation_list_id == res.moderation_list_id 

1039 assert len(listRes.moderation_lists[0].members) == 3 

1040 assert {user1.id, user2.id, user3.id}.issubset({m.user_id for m in listRes.moderation_lists[0].members}) 

1041 

1042 # Test user can be in multiple moderation lists 

1043 listRes3 = api.ListModerationUserLists(admin_pb2.ListModerationUserListsReq(user=user1.username)) 

1044 assert len(listRes3.moderation_lists) == 2 

1045 

1046 # Test adding users to an existing moderation list 

1047 res2 = api.AddUsersToModerationUserList( 

1048 admin_pb2.AddUsersToModerationUserListReq( 

1049 users=[user4.username, user5.username], moderation_list_id=moderation_list_id 

1050 ), 

1051 ) 

1052 assert res2.moderation_list_id == moderation_list_id 

1053 with session_scope() as session: 

1054 moderation_user_list = session.get_one(ModerationUserList, moderation_list_id) 

1055 assert len(moderation_user_list.users) == 3 

1056 assert {user1.id, user4.id, user5.id}.issubset({user.id for user in moderation_user_list.users}) 

1057 

1058 # Test list user moderation lists endpoint returns the right moderation list 

1059 listRes2 = api.ListModerationUserLists(admin_pb2.ListModerationUserListsReq(user=user5.username)) 

1060 assert len(listRes2.moderation_lists) == 1 

1061 assert listRes2.moderation_lists[0].moderation_list_id == moderation_list_id 

1062 assert len(listRes2.moderation_lists[0].members) == 3 

1063 assert {user1.id, user4.id, user5.id}.issubset({m.user_id for m in listRes2.moderation_lists[0].members}) 

1064 

1065 

1066def test_RemoveUserFromModerationUserList(db): 

1067 super_user, super_token = generate_user(is_superuser=True) 

1068 user1, _ = generate_user() 

1069 user2, _ = generate_user() 

1070 user3, _ = generate_user() 

1071 moderation_list_id = add_users_to_new_moderation_list([user1, user2]) 

1072 

1073 with real_admin_session(super_token) as api: 

1074 # Test with non-existent user (should raise error) 

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

1076 api.RemoveUserFromModerationUserList(admin_pb2.RemoveUserFromModerationUserListReq(user="nonexistent")) 

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

1078 assert "Couldn't find that user." == e.value.details() 

1079 

1080 # Test without providing moderation list id (should raise error) 

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

1082 api.RemoveUserFromModerationUserList(admin_pb2.RemoveUserFromModerationUserListReq(user=user2.username)) 

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

1084 assert "Missing moderation user list id." == e.value.details() 

1085 

1086 # Test removing user that's not in the provided moderation list (should raise error) 

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

1088 api.RemoveUserFromModerationUserList( 

1089 admin_pb2.RemoveUserFromModerationUserListReq( 

1090 user=user3.username, moderation_list_id=moderation_list_id 

1091 ) 

1092 ) 

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

1094 assert "User is not in the moderation user list." == e.value.details() 

1095 

1096 # Test successful removal 

1097 api.RemoveUserFromModerationUserList( 

1098 admin_pb2.RemoveUserFromModerationUserListReq(user=user1.username, moderation_list_id=moderation_list_id) 

1099 ) 

1100 with session_scope() as session: 

1101 moderation_user_list = session.get_one(ModerationUserList, moderation_list_id) 

1102 assert user1.id not in {user.id for user in moderation_user_list.users} 

1103 assert user2.id in {user.id for user in moderation_user_list.users} 

1104 

1105 # Test list user moderation lists endpoint returns right number of moderation lists 

1106 listRes = api.ListModerationUserLists(admin_pb2.ListModerationUserListsReq(user=user1.username)) 

1107 assert len(listRes.moderation_lists) == 0 

1108 listRes2 = api.ListModerationUserLists(admin_pb2.ListModerationUserListsReq(user=user2.username)) 

1109 assert len(listRes2.moderation_lists) == 1 

1110 

1111 # Test removing all users from moderation list should also delete the moderation list 

1112 api.RemoveUserFromModerationUserList( 

1113 admin_pb2.RemoveUserFromModerationUserListReq(user=user2.username, moderation_list_id=moderation_list_id) 

1114 ) 

1115 with session_scope() as session: 

1116 assert session.get(ModerationUserList, moderation_list_id) is None 

1117 

1118 

1119def test_admin_delete_account_url(db, email_collector: EmailCollector, push_collector: PushCollector): 

1120 super_user, super_token = generate_user(is_superuser=True) 

1121 

1122 user, token = generate_user() 

1123 user_id = user.id 

1124 

1125 with real_admin_session(super_token) as admin_api: 

1126 url = admin_api.CreateAccountDeletionLink( 

1127 admin_pb2.CreateAccountDeletionLinkReq(user=user.username) 

1128 ).account_deletion_confirm_url 

1129 

1130 assert push_collector.count_for_user(user_id) == 0 

1131 

1132 with session_scope() as session: 

1133 token_o = session.execute(select(AccountDeletionToken)).scalar_one() 

1134 token = token_o.token 

1135 assert token_o.user.id == user_id 

1136 assert url == f"http://localhost:3000/delete-account?token={token}" 

1137 

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

1139 auth_api.ConfirmDeleteAccount( 

1140 auth_pb2.ConfirmDeleteAccountReq( 

1141 token=token, 

1142 ) 

1143 ) 

1144 

1145 push = push_collector.pop_for_user(user_id, last=True) 

1146 assert push.content.title == "Account deleted" 

1147 assert push.content.body == "You can restore it within 7 days using the link we emailed you." 

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

1149 

1150 

1151def test_AccessStats(db): 

1152 super_user, super_token = generate_user(is_superuser=True) 

1153 normal_user, normal_token = generate_user() 

1154 

1155 # Insert UserActivity rows: a couple inside the default 90-day window, one well 

1156 # outside it, and one with NULL ip_address / user_agent. The INET column is 

1157 # returned by psycopg3 as an IPv4Address/IPv6Address object, which used to 

1158 # crash the proto string assignment. 

1159 in_window_1 = now() - timedelta(days=1) 

1160 in_window_2 = now() - timedelta(days=10) 

1161 out_of_window = now() - timedelta(days=200) 

1162 with session_scope() as session: 

1163 session.add( 

1164 UserActivity( 

1165 user_id=normal_user.id, period=in_window_1, ip_address="1.2.3.4", user_agent="ua-a", api_calls=5 

1166 ) 

1167 ) 

1168 session.add( 

1169 UserActivity( 

1170 user_id=normal_user.id, period=in_window_2, ip_address="2001:db8::1", user_agent="ua-b", api_calls=3 

1171 ) 

1172 ) 

1173 session.add( 

1174 UserActivity( 

1175 user_id=normal_user.id, period=out_of_window, ip_address="9.9.9.9", user_agent="ua-old", api_calls=99 

1176 ) 

1177 ) 

1178 session.add(UserActivity(user_id=normal_user.id, period=in_window_1, api_calls=1)) 

1179 

1180 with real_admin_session(super_token) as api: 

1181 res = api.AccessStats(admin_pb2.AccessStatsReq(user=normal_user.username)) 

1182 

1183 by_ip = {s.ip_address: s for s in res.stats} 

1184 assert "1.2.3.4" in by_ip 

1185 assert by_ip["1.2.3.4"].api_call_count == 5 

1186 assert by_ip["1.2.3.4"].user_agent == "ua-a" 

1187 assert "2001:db8::1" in by_ip 

1188 assert by_ip["2001:db8::1"].api_call_count == 3 

1189 # NULL ip_address row produces an empty-string ip_address in the proto 

1190 assert "" in by_ip 

1191 assert by_ip[""].api_call_count == 1 

1192 # out-of-window row is excluded by the 90-day default 

1193 assert "9.9.9.9" not in by_ip 

1194 

1195 # explicit end_time should bound the upper end of the window (regression: was >=) 

1196 with real_admin_session(super_token) as api: 

1197 res = api.AccessStats( 

1198 admin_pb2.AccessStatsReq( 

1199 user=normal_user.username, 

1200 start_time=Timestamp_from_datetime(now() - timedelta(days=5)), 

1201 end_time=Timestamp_from_datetime(now()), 

1202 ) 

1203 ) 

1204 ips = {s.ip_address for s in res.stats} 

1205 assert ips == {"1.2.3.4", ""} 

1206 

1207 

1208def test_SetLastDonated(db): 

1209 super_user, super_token = generate_user(is_superuser=True) 

1210 normal_user, normal_token = generate_user(last_donated=None) 

1211 

1212 with real_admin_session(super_token) as api: 

1213 # user starts with no last_donated 

1214 with session_scope() as session: 

1215 user = session.execute(select(User).where(User.id == normal_user.id)).scalar_one() 

1216 assert user.last_donated is None 

1217 

1218 # can set last_donated 

1219 donation_time = now() - timedelta(days=30) 

1220 res = api.SetLastDonated( 

1221 admin_pb2.SetLastDonatedReq( 

1222 user=normal_user.username, 

1223 last_donated=Timestamp_from_datetime(donation_time), 

1224 ) 

1225 ) 

1226 

1227 with session_scope() as session: 

1228 user = session.execute(select(User).where(User.id == normal_user.id)).scalar_one() 

1229 assert user.last_donated is not None 

1230 # check timestamp is close (within a second) 

1231 assert abs((user.last_donated - donation_time).total_seconds()) < 1 

1232 

1233 # can clear last_donated by not setting the field 

1234 res = api.SetLastDonated(admin_pb2.SetLastDonatedReq(user=normal_user.username)) 

1235 

1236 with session_scope() as session: 

1237 user = session.execute(select(User).where(User.id == normal_user.id)).scalar_one() 

1238 assert user.last_donated is None 

1239 

1240 # user not found 

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

1242 api.SetLastDonated(admin_pb2.SetLastDonatedReq(user="nonexistent")) 

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

1244 assert e.value.details() == "Couldn't find that user." 

1245 

1246 

1247def test_admin_actions_level(db): 

1248 super_user, super_token = generate_user(is_superuser=True) 

1249 normal_user, _ = generate_user() 

1250 

1251 with real_admin_session(super_token) as api: 

1252 # Default level is NORMAL 

1253 res = api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=normal_user.username, admin_note="normal note")) 

1254 assert res.admin_actions[0].level == admin_pb2.ADMIN_ACTION_LEVEL_NORMAL 

1255 

1256 # Explicitly set to DEBUG 

1257 res = api.AddAdminNote( 

1258 admin_pb2.AddAdminNoteReq( 

1259 user=normal_user.username, 

1260 admin_note="debug note", 

1261 level=admin_pb2.ADMIN_ACTION_LEVEL_DEBUG, 

1262 ) 

1263 ) 

1264 assert len(res.admin_actions) == 2 

1265 assert res.admin_actions[1].level == admin_pb2.ADMIN_ACTION_LEVEL_DEBUG 

1266 

1267 # Explicitly set to HIGH 

1268 res = api.AddAdminNote( 

1269 admin_pb2.AddAdminNoteReq( 

1270 user=normal_user.username, 

1271 admin_note="high note", 

1272 level=admin_pb2.ADMIN_ACTION_LEVEL_HIGH, 

1273 ) 

1274 ) 

1275 assert len(res.admin_actions) == 3 

1276 assert res.admin_actions[2].level == admin_pb2.ADMIN_ACTION_LEVEL_HIGH 

1277 

1278 

1279def test_admin_actions_on_mutations(db, push_collector: PushCollector): 

1280 super_user, super_token = generate_user(is_superuser=True) 

1281 normal_user, _ = generate_user() 

1282 

1283 original_gender = normal_user.gender 

1284 original_birthdate = normal_user.birthdate 

1285 

1286 with real_admin_session(super_token) as api: 

1287 # ChangeUserGender 

1288 res = api.ChangeUserGender(admin_pb2.ChangeUserGenderReq(user=normal_user.username, gender="Machine")) 

1289 assert any( 

1290 a.action_type == "change_gender" and a.note == f"Changed from '{original_gender}' to 'Machine'" 

1291 for a in res.admin_actions 

1292 ) 

1293 

1294 # ChangeUserBirthdate 

1295 res = api.ChangeUserBirthdate( 

1296 admin_pb2.ChangeUserBirthdateReq(user=normal_user.username, birthdate="1990-01-01") 

1297 ) 

1298 assert any( 

1299 a.action_type == "change_birthdate" and a.note == f"Changed from {original_birthdate} to 1990-01-01" 

1300 for a in res.admin_actions 

1301 ) 

1302 

1303 # SetPassportSexGenderException 

1304 res = api.SetPassportSexGenderException( 

1305 admin_pb2.SetPassportSexGenderExceptionReq(user=normal_user.username, passport_sex_gender_exception=True) 

1306 ) 

1307 assert any( 

1308 a.action_type == "set_passport_sex_gender_exception" and a.note == "Changed from False to True" 

1309 for a in res.admin_actions 

1310 ) 

1311 

1312 # SendModNote with notify 

1313 res = api.SendModNote( 

1314 admin_pb2.SendModNoteReq( 

1315 user=normal_user.username, content="Please update your profile", internal_id="test1" 

1316 ) 

1317 ) 

1318 assert any( 

1319 a.action_type == "send_mod_note" and a.note == "Notify user: Yes\n\nPlease update your profile" 

1320 for a in res.admin_actions 

1321 ) 

1322 

1323 # SendModNote with do_not_notify 

1324 res = api.SendModNote( 

1325 admin_pb2.SendModNoteReq( 

1326 user=normal_user.username, 

1327 content="Silent note", 

1328 internal_id="test2", 

1329 do_not_notify=True, 

1330 ) 

1331 ) 

1332 assert any( 

1333 a.action_type == "send_mod_note" and a.note == "Notify user: No\n\nSilent note" for a in res.admin_actions 

1334 ) 

1335 

1336 # DeleteUser 

1337 res = api.DeleteUser(admin_pb2.DeleteUserReq(user=normal_user.username)) 

1338 assert any(a.action_type == "delete_user" for a in res.admin_actions) 

1339 assert any( 

1340 a.action_type == "delete_user" and a.level == admin_pb2.ADMIN_ACTION_LEVEL_HIGH for a in res.admin_actions 

1341 ) 

1342 

1343 # RecoverDeletedUser 

1344 res = api.RecoverDeletedUser(admin_pb2.RecoverDeletedUserReq(user=normal_user.username)) 

1345 assert any(a.action_type == "recover_user" for a in res.admin_actions) 

1346 

1347 # MarkUserNeedsLocationUpdate 

1348 res = api.MarkUserNeedsLocationUpdate(admin_pb2.MarkUserNeedsLocationUpdateReq(user=normal_user.username)) 

1349 assert any( 

1350 a.action_type == "mark_needs_location_update" and a.note == "Marked user as needing location update" 

1351 for a in res.admin_actions 

1352 ) 

1353 

1354 # SetLastDonated 

1355 res = api.SetLastDonated( 

1356 admin_pb2.SetLastDonatedReq( 

1357 user=normal_user.username, 

1358 last_donated=Timestamp_from_datetime(now()), 

1359 ) 

1360 ) 

1361 assert any(a.action_type == "set_last_donated" for a in res.admin_actions) 

1362 

1363 

1364def test_create_admin_tag(db): 

1365 super_user, super_token = generate_user(is_superuser=True) 

1366 

1367 with real_admin_session(super_token) as api: 

1368 res = api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="test-tag")) 

1369 assert res.tag == "test-tag" 

1370 assert res.admin_tag_id > 0 

1371 

1372 

1373def test_create_admin_tag_duplicate(db): 

1374 super_user, super_token = generate_user(is_superuser=True) 

1375 

1376 with real_admin_session(super_token) as api: 

1377 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="test-tag")) 

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

1379 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="test-tag")) 

1380 assert e.value.code() == grpc.StatusCode.ALREADY_EXISTS 

1381 assert e.value.details() == "That admin tag already exists." 

1382 

1383 

1384def test_create_admin_tag_empty(db): 

1385 super_user, super_token = generate_user(is_superuser=True) 

1386 

1387 with real_admin_session(super_token) as api: 

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

1389 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="")) 

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

1391 assert e.value.details() == "The admin tag cannot be empty." 

1392 

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

1394 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag=" ")) 

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

1396 assert e.value.details() == "The admin tag cannot be empty." 

1397 

1398 

1399def test_list_admin_tags(db): 

1400 super_user, super_token = generate_user(is_superuser=True) 

1401 

1402 with real_admin_session(super_token) as api: 

1403 # Empty initially 

1404 res = api.ListAdminTags(admin_pb2.ListAdminTagsReq()) 

1405 assert len(res.tags) == 0 

1406 

1407 # Add some tags 

1408 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="bravo")) 

1409 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="alpha")) 

1410 

1411 res = api.ListAdminTags(admin_pb2.ListAdminTagsReq()) 

1412 assert len(res.tags) == 2 

1413 # Ordered alphabetically 

1414 assert res.tags[0].tag == "alpha" 

1415 assert res.tags[1].tag == "bravo" 

1416 

1417 

1418def test_add_admin_tag_to_user(db): 

1419 super_user, super_token = generate_user(is_superuser=True) 

1420 normal_user, _ = generate_user() 

1421 

1422 with real_admin_session(super_token) as api: 

1423 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="vip")) 

1424 

1425 res = api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=normal_user.username, tag="vip")) 

1426 assert "vip" in res.admin_tags 

1427 assert any(a.action_type == "add_tag" and a.tag == "vip" for a in res.admin_actions) 

1428 

1429 

1430def test_add_admin_tag_to_user_duplicate(db): 

1431 super_user, super_token = generate_user(is_superuser=True) 

1432 normal_user, _ = generate_user() 

1433 

1434 with real_admin_session(super_token) as api: 

1435 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="vip")) 

1436 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=normal_user.username, tag="vip")) 

1437 

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

1439 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=normal_user.username, tag="vip")) 

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

1441 assert e.value.details() == "The user already has that admin tag." 

1442 

1443 

1444def test_add_admin_tag_to_user_tag_not_found(db): 

1445 super_user, super_token = generate_user(is_superuser=True) 

1446 normal_user, _ = generate_user() 

1447 

1448 with real_admin_session(super_token) as api: 

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

1450 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=normal_user.username, tag="nonexistent")) 

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

1452 assert e.value.details() == "Admin tag not found." 

1453 

1454 

1455def test_remove_admin_tag_from_user(db): 

1456 super_user, super_token = generate_user(is_superuser=True) 

1457 normal_user, _ = generate_user() 

1458 

1459 with real_admin_session(super_token) as api: 

1460 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="vip")) 

1461 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=normal_user.username, tag="vip")) 

1462 

1463 res = api.RemoveAdminTagFromUser(admin_pb2.RemoveAdminTagFromUserReq(user=normal_user.username, tag="vip")) 

1464 assert "vip" not in res.admin_tags 

1465 assert any(a.action_type == "remove_tag" and a.tag == "vip" for a in res.admin_actions) 

1466 

1467 

1468def test_remove_admin_tag_from_user_not_assigned(db): 

1469 super_user, super_token = generate_user(is_superuser=True) 

1470 normal_user, _ = generate_user() 

1471 

1472 with real_admin_session(super_token) as api: 

1473 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="vip")) 

1474 

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

1476 api.RemoveAdminTagFromUser(admin_pb2.RemoveAdminTagFromUserReq(user=normal_user.username, tag="vip")) 

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

1478 assert e.value.details() == "The user does not have that admin tag." 

1479 

1480 

1481def test_search_users_by_admin_tag(db): 

1482 super_user, super_token = generate_user(is_superuser=True) 

1483 user1, _ = generate_user() 

1484 user2, _ = generate_user() 

1485 user3, _ = generate_user() 

1486 

1487 with real_admin_session(super_token) as api: 

1488 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="vip")) 

1489 api.CreateAdminTag(admin_pb2.CreateAdminTagReq(tag="flagged")) 

1490 

1491 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=user1.username, tag="vip")) 

1492 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=user2.username, tag="vip")) 

1493 api.AddAdminTagToUser(admin_pb2.AddAdminTagToUserReq(user=user2.username, tag="flagged")) 

1494 

1495 # Search for users with "vip" tag 

1496 res = api.SearchUsers(admin_pb2.SearchUsersReq(admin_tags=["vip"])) 

1497 user_ids = {u.user_id for u in res.users} 

1498 assert user1.id in user_ids 

1499 assert user2.id in user_ids 

1500 assert user3.id not in user_ids 

1501 

1502 # Search for users with both "vip" AND "flagged" tags (AND logic) 

1503 res = api.SearchUsers(admin_pb2.SearchUsersReq(admin_tags=["vip", "flagged"])) 

1504 user_ids = {u.user_id for u in res.users} 

1505 assert user2.id in user_ids 

1506 assert user1.id not in user_ids 

1507 

1508 # Search for non-existent tag returns no results 

1509 res = api.SearchUsers(admin_pb2.SearchUsersReq(admin_tags=["nonexistent"])) 

1510 assert len(res.users) == 0 

1511 

1512 

1513def test_search_users_by_admin_note(db): 

1514 super_user, super_token = generate_user(is_superuser=True) 

1515 user1, _ = generate_user() 

1516 user2, _ = generate_user() 

1517 

1518 with real_admin_session(super_token) as api: 

1519 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="suspicious activity")) 

1520 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user2.username, admin_note="normal user")) 

1521 

1522 # Search by admin action log content (ilike) 

1523 res = api.SearchUsers(admin_pb2.SearchUsersReq(admin_action_log="%suspicious%")) 

1524 user_ids = {u.user_id for u in res.users} 

1525 assert user1.id in user_ids 

1526 assert user2.id not in user_ids 

1527 

1528 

1529def test_ListAdminActions_empty(db): 

1530 super_user, super_token = generate_user(is_superuser=True) 

1531 

1532 with real_admin_session(super_token) as api: 

1533 res = api.ListAdminActions(admin_pb2.ListAdminActionsReq()) 

1534 assert len(res.admin_actions) == 0 

1535 assert res.next_page_token == "" 

1536 

1537 

1538def test_ListAdminActions_returns_newest_first_with_target_info(db): 

1539 super_user, super_token = generate_user(is_superuser=True) 

1540 user1, _ = generate_user() 

1541 user2, _ = generate_user() 

1542 

1543 with real_admin_session(super_token) as api: 

1544 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="first note")) 

1545 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user2.username, admin_note="second note")) 

1546 api.BanUser(admin_pb2.BanUserReq(user=user1.username, admin_note="ban reason")) 

1547 

1548 res = api.ListAdminActions(admin_pb2.ListAdminActionsReq()) 

1549 

1550 assert len(res.admin_actions) == 3 

1551 # Newest first 

1552 assert res.admin_actions[0].action_type == "ban" 

1553 assert res.admin_actions[0].target_user_id == user1.id 

1554 assert res.admin_actions[0].target_username == user1.username 

1555 assert res.admin_actions[0].admin_user_id == super_user.id 

1556 assert res.admin_actions[0].admin_username == super_user.username 

1557 assert res.admin_actions[1].action_type == "note" 

1558 assert res.admin_actions[1].target_user_id == user2.id 

1559 assert res.admin_actions[2].action_type == "note" 

1560 assert res.admin_actions[2].target_user_id == user1.id 

1561 

1562 

1563def test_ListAdminActions_filter_by_admin_and_target(db): 

1564 super1, super1_token = generate_user(is_superuser=True) 

1565 super2, super2_token = generate_user(is_superuser=True) 

1566 user1, _ = generate_user() 

1567 user2, _ = generate_user() 

1568 

1569 with real_admin_session(super1_token) as api: 

1570 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="from super1 to user1")) 

1571 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user2.username, admin_note="from super1 to user2")) 

1572 with real_admin_session(super2_token) as api: 

1573 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user1.username, admin_note="from super2 to user1")) 

1574 

1575 with real_admin_session(super1_token) as api: 

1576 res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(admin_user_id=super1.id)) 

1577 assert {a.note for a in res.admin_actions} == {"from super1 to user1", "from super1 to user2"} 

1578 

1579 res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(target_user_id=user1.id)) 

1580 assert {a.note for a in res.admin_actions} == {"from super1 to user1", "from super2 to user1"} 

1581 

1582 res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(admin_user_id=super1.id, target_user_id=user1.id)) 

1583 assert [a.note for a in res.admin_actions] == ["from super1 to user1"] 

1584 

1585 

1586def test_ListAdminActions_pagination(db): 

1587 super_user, super_token = generate_user(is_superuser=True) 

1588 user, _ = generate_user() 

1589 

1590 with real_admin_session(super_token) as api: 

1591 for i in range(3): 

1592 api.AddAdminNote(admin_pb2.AddAdminNoteReq(user=user.username, admin_note=f"note {i}")) 

1593 

1594 res = api.ListAdminActions(admin_pb2.ListAdminActionsReq(page_size=2)) 

1595 assert len(res.admin_actions) == 2 

1596 assert res.next_page_token != "" 

1597 first_page_notes = [a.note for a in res.admin_actions] 

1598 

1599 res2 = api.ListAdminActions(admin_pb2.ListAdminActionsReq(page_size=2, page_token=res.next_page_token)) 

1600 assert len(res2.admin_actions) == 1 

1601 assert res2.next_page_token == "" 

1602 

1603 all_notes = first_page_notes + [a.note for a in res2.admin_actions] 

1604 assert set(all_notes) == {"note 0", "note 1", "note 2"} 

1605 

1606 

1607def test_ListUserUploads(db): 

1608 super_user, super_token = generate_user(is_superuser=True) 

1609 user, _ = generate_user(complete_profile=False) 

1610 other_user, _ = generate_user() 

1611 

1612 with session_scope() as session: 

1613 for i in range(3): 

1614 session.add( 

1615 Upload( 

1616 key=f"key{i}", 

1617 filename=f"photo{i}.jpg", 

1618 creator_user_id=user.id, 

1619 credit=f"credit {i}" if i == 0 else None, 

1620 ) 

1621 ) 

1622 session.add(Upload(key="other_key", filename="other.jpg", creator_user_id=other_user.id)) 

1623 

1624 with real_admin_session(super_token) as api: 

1625 res = api.ListUserUploads(admin_pb2.ListUserUploadsReq(user=user.username)) 

1626 

1627 assert len(res.uploads) == 3 

1628 assert res.next_page_token == "" 

1629 assert {u.filename for u in res.uploads} == {"photo0.jpg", "photo1.jpg", "photo2.jpg"} 

1630 

1631 upload0 = next(u for u in res.uploads if u.key == "key0") 

1632 assert upload0.credit == "credit 0" 

1633 assert upload0.full_url.endswith("/img/full/photo0.jpg") 

1634 assert upload0.thumbnail_url.endswith("/img/thumbnail/photo0.jpg") 

1635 assert upload0.HasField("created") 

1636 

1637 

1638def test_ListUserUploads_pagination(db): 

1639 super_user, super_token = generate_user(is_superuser=True) 

1640 user, _ = generate_user(complete_profile=False) 

1641 

1642 with session_scope() as session: 

1643 for i in range(3): 

1644 session.add(Upload(key=f"key{i}", filename=f"photo{i}.jpg", creator_user_id=user.id)) 

1645 

1646 with real_admin_session(super_token) as api: 

1647 res = api.ListUserUploads(admin_pb2.ListUserUploadsReq(user=user.username, page_size=2)) 

1648 assert len(res.uploads) == 2 

1649 assert res.next_page_token != "" 

1650 first_page_keys = [u.key for u in res.uploads] 

1651 

1652 res2 = api.ListUserUploads( 

1653 admin_pb2.ListUserUploadsReq(user=user.username, page_size=2, page_token=res.next_page_token) 

1654 ) 

1655 assert len(res2.uploads) == 1 

1656 assert res2.next_page_token == "" 

1657 

1658 all_keys = first_page_keys + [u.key for u in res2.uploads] 

1659 assert set(all_keys) == {"key0", "key1", "key2"} 

1660 

1661 

1662def test_ListUserUploads_not_found(db): 

1663 super_user, super_token = generate_user(is_superuser=True) 

1664 

1665 with real_admin_session(super_token) as api: 

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

1667 api.ListUserUploads(admin_pb2.ListUserUploadsReq(user="nonexistent")) 

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

1669 

1670 

1671# community invite feature tested in test_events.py 

1672# SendBlogPostNotification tested in test_notifications.py 

1673# MarkUserNeedsLocationUpdate tested in test_jail.py 

1674 

1675 

1676def _ota_manifest(*, version, fingerprint, created_at="2026-05-31T00:00:00.000Z"): 

1677 return { 

1678 "id": f"id-{version}", 

1679 "createdAt": created_at, 

1680 "runtimeVersion": fingerprint, 

1681 "launchAsset": {"key": "bundle", "url": f"https://cdn.testing.invalid/{version}/bundle.hbc"}, 

1682 "assets": [], 

1683 "metadata": {}, 

1684 "extra": {}, 

1685 } 

1686 

1687 

1688def _ota_signed_multipart(manifest): 

1689 # Mimics the signed multipart body the CDN holds (signature header omitted; we only read the JSON). 

1690 boundary = "COUCHERS_OTA_BOUNDARY" 

1691 

1692 def part(name, body, content_type): 

1693 return f'--{boundary}\r\ncontent-disposition: form-data; name="{name}"\r\ncontent-type: {content_type}\r\n\r\n{body}\r\n' 

1694 

1695 body = ( 

1696 part("manifest", json.dumps(manifest), "application/json; charset=utf-8") 

1697 + part("extensions", "{}", "application/json") 

1698 + f"--{boundary}--\r\n" 

1699 ) 

1700 return f"multipart/mixed; boundary={boundary}", body.encode() 

1701 

1702 

1703def _patch_ota_cdn(manifests): 

1704 # manifests: {version: manifest_dict}. URL is {cdn_root}/{version}/{platform}/manifest. 

1705 def fake(url): 

1706 version = url.split("/")[-3] 

1707 if version not in manifests: 

1708 return "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY", b"" 

1709 return _ota_signed_multipart(manifests[version]) 

1710 

1711 return patch("couchers.servicers.admin._fetch_signed_manifest", side_effect=fake) 

1712 

1713 

1714def test_CreateOTAPackage(db): 

1715 super_user, super_token = generate_user(is_superuser=True) 

1716 

1717 manifests = {"v1.3.1.aaaa": _ota_manifest(version="v1.3.1.aaaa", fingerprint="ios-fp")} 

1718 with _patch_ota_cdn(manifests), real_admin_session(super_token) as api: 

1719 res = api.CreateOTAPackage( 

1720 admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.1.aaaa") 

1721 ) 

1722 

1723 assert res.platform == admin_pb2.OTA_PLATFORM_IOS 

1724 assert res.fingerprint == "ios-fp" 

1725 assert res.version == "v1.3.1.aaaa" 

1726 assert res.manifest_id == "id-v1.3.1.aaaa" 

1727 assert res.banned is False 

1728 assert res.live is True 

1729 assert res.creator_user_id == super_user.id 

1730 

1731 

1732def test_CreateOTAPackage_invalid(db): 

1733 _, super_token = generate_user(is_superuser=True) 

1734 

1735 manifests = {"v-incomplete": {"id": "x"}} # on the CDN but missing runtimeVersion / createdAt 

1736 with _patch_ota_cdn(manifests), real_admin_session(super_token) as api: 

1737 # missing version 

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

1739 api.CreateOTAPackage(admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS)) 

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

1741 

1742 # nothing published at this version 

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

1744 api.CreateOTAPackage( 

1745 admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v-missing") 

1746 ) 

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

1748 

1749 # manifest present but missing required fields 

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

1751 api.CreateOTAPackage( 

1752 admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v-incomplete") 

1753 ) 

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

1755 

1756 

1757def test_CreateOTAPackage_rejects_duplicate_version(db): 

1758 _, super_token = generate_user(is_superuser=True) 

1759 

1760 manifests = {"v1.3.1.aaaa": _ota_manifest(version="v1.3.1.aaaa", fingerprint="ios-fp")} 

1761 with _patch_ota_cdn(manifests), real_admin_session(super_token) as api: 

1762 api.CreateOTAPackage(admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.1.aaaa")) 

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

1764 api.CreateOTAPackage( 

1765 admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.1.aaaa") 

1766 ) 

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

1768 

1769 

1770def test_ListOTAPackages(db): 

1771 _, super_token = generate_user(is_superuser=True) 

1772 

1773 manifests = { 

1774 "v1.3.1.ios": _ota_manifest(version="v1.3.1.ios", fingerprint="ios-fp", created_at="2026-05-30T00:00:00.000Z"), 

1775 "v1.3.2.ios": _ota_manifest(version="v1.3.2.ios", fingerprint="ios-fp", created_at="2026-05-31T00:00:00.000Z"), 

1776 "v1.3.2.android": _ota_manifest( 

1777 version="v1.3.2.android", fingerprint="android-fp", created_at="2026-06-01T00:00:00.000Z" 

1778 ), 

1779 } 

1780 with _patch_ota_cdn(manifests), real_admin_session(super_token) as api: 

1781 api.CreateOTAPackage(admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.1.ios")) 

1782 api.CreateOTAPackage(admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.2.ios")) 

1783 api.CreateOTAPackage( 

1784 admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_ANDROID, version="v1.3.2.android") 

1785 ) 

1786 

1787 res = api.ListOTAPackages(admin_pb2.ListOTAPackagesReq()) 

1788 # newest (by manifest createdAt) first 

1789 assert [p.version for p in res.packages] == ["v1.3.2.android", "v1.3.2.ios", "v1.3.1.ios"] 

1790 # only the newest per (platform, fingerprint) is live 

1791 live = {p.version: p.live for p in res.packages} 

1792 assert live == {"v1.3.2.android": True, "v1.3.2.ios": True, "v1.3.1.ios": False} 

1793 

1794 ios = api.ListOTAPackages(admin_pb2.ListOTAPackagesReq(platform=admin_pb2.OTA_PLATFORM_IOS)) 

1795 assert [p.version for p in ios.packages] == ["v1.3.2.ios", "v1.3.1.ios"] 

1796 

1797 

1798def test_BanOTAPackage(db): 

1799 super_user, super_token = generate_user(is_superuser=True) 

1800 

1801 manifests = { 

1802 "v1.3.1.good": _ota_manifest( 

1803 version="v1.3.1.good", fingerprint="ios-fp", created_at="2026-05-30T00:00:00.000Z" 

1804 ), 

1805 "v1.3.2.bad": _ota_manifest(version="v1.3.2.bad", fingerprint="ios-fp", created_at="2026-05-31T00:00:00.000Z"), 

1806 } 

1807 with _patch_ota_cdn(manifests), real_admin_session(super_token) as api: 

1808 api.CreateOTAPackage(admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.1.good")) 

1809 second = api.CreateOTAPackage( 

1810 admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.2.bad") 

1811 ) 

1812 assert second.live is True 

1813 

1814 banned = api.BanOTAPackage( 

1815 admin_pb2.BanOTAPackageReq(ota_package_id=second.ota_package_id, reason="bad bundle") 

1816 ) 

1817 assert banned.banned is True 

1818 assert banned.banned_reason == "bad bundle" 

1819 assert banned.banned_by_user_id == super_user.id 

1820 assert banned.live is False 

1821 

1822 # banning the newest stops new check-ins getting it; the previous one becomes live again 

1823 res = api.ListOTAPackages(admin_pb2.ListOTAPackagesReq(include_banned=True)) 

1824 live = {p.version: p.live for p in res.packages} 

1825 assert live == {"v1.3.2.bad": False, "v1.3.1.good": True} 

1826 

1827 # banned packages are excluded by default 

1828 non_banned = api.ListOTAPackages(admin_pb2.ListOTAPackagesReq()) 

1829 assert [p.version for p in non_banned.packages] == ["v1.3.1.good"] 

1830 

1831 

1832def test_BanOTAPackage_requires_reason(db): 

1833 _, super_token = generate_user(is_superuser=True) 

1834 

1835 manifests = { 

1836 "v1.3.1": _ota_manifest(version="v1.3.1", fingerprint="ios-fp", created_at="2026-05-30T00:00:00.000Z"), 

1837 } 

1838 with _patch_ota_cdn(manifests), real_admin_session(super_token) as api: 

1839 pkg = api.CreateOTAPackage(admin_pb2.CreateOTAPackageReq(platform=admin_pb2.OTA_PLATFORM_IOS, version="v1.3.1")) 

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

1841 api.BanOTAPackage(admin_pb2.BanOTAPackageReq(ota_package_id=pkg.ota_package_id)) 

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

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

1844 api.BanOTAPackage(admin_pb2.BanOTAPackageReq(ota_package_id=pkg.ota_package_id, reason=" ")) 

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

1846 

1847 

1848def test_BanOTAPackage_not_found(db): 

1849 _, super_token = generate_user(is_superuser=True) 

1850 

1851 with real_admin_session(super_token) as api: 

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

1853 api.BanOTAPackage(admin_pb2.BanOTAPackageReq(ota_package_id=123456, reason="never mind")) 

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