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

1155 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +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 HostRequest, 

18 ModerationObjectType, 

19 ModerationState, 

20 ModerationUserList, 

21 ModerationVisibility, 

22 NonvisibleUserAccess, 

23 NonvisibleUserAccessType, 

24 NonvisibleUserState, 

25 PhotoGallery, 

26 PhotoGalleryItem, 

27 Reference, 

28 Upload, 

29 User, 

30 UserActivity, 

31 UserSession, 

32) 

33from couchers.proto import ( 

34 account_pb2, 

35 admin_pb2, 

36 auth_pb2, 

37 blocking_pb2, 

38 events_pb2, 

39 references_pb2, 

40 reporting_pb2, 

41 requests_pb2, 

42) 

43from couchers.utils import Timestamp_from_datetime, date_to_api, datetime_to_iso8601_local, now, parse_date 

44from tests.fixtures.db import ( 

45 add_users_to_new_moderation_list, 

46 backdate_conversations, 

47 generate_user, 

48 make_friends, 

49) 

50from tests.fixtures.misc import EmailCollector, Moderator, PushCollector 

51from tests.fixtures.sessions import ( 

52 account_session, 

53 auth_api_session, 

54 blocking_session, 

55 events_session, 

56 real_admin_session, 

57 references_session, 

58 reporting_session, 

59 requests_session, 

60) 

61from tests.test_communities import create_community 

62from tests.test_references import create_host_reference 

63from tests.test_requests import valid_request_text 

64 

65 

66def test_access_by_normal_user(db): 

67 normal_user, normal_token = generate_user() 

68 

69 with real_admin_session(normal_token) as api: 

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

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

72 api.GetUserDetails( 

73 admin_pb2.GetUserDetailsReq( 

74 user=str(normal_user.id), 

75 ) 

76 ) 

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

78 

79 

80def test_GetNonvisibleUserAccessLog(db): 

81 super_user, super_token = generate_user(is_superuser=True) 

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

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

84 

85 with session_scope() as session: 

86 session.add( 

87 NonvisibleUserAccess( 

88 access_type=NonvisibleUserAccessType.login_attempt, 

89 target_user_id=target.id, 

90 target_state=NonvisibleUserState.banned, 

91 actor_user_id=target.id, 

92 ip_address="1.2.3.4", 

93 sofa="device-cookie", 

94 ) 

95 ) 

96 session.add( 

97 NonvisibleUserAccess( 

98 access_type=NonvisibleUserAccessType.ghost_served, 

99 target_user_id=target.id, 

100 target_state=NonvisibleUserState.banned, 

101 actor_user_id=viewer.id, 

102 ) 

103 ) 

104 session.add( 

105 NonvisibleUserAccess( 

106 access_type=NonvisibleUserAccessType.ghost_served, 

107 target_user_id=target.id, 

108 target_state=NonvisibleUserState.banned, 

109 actor_user_id=None, 

110 ) 

111 ) 

112 

113 with real_admin_session(super_token) as api: 

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

115 

116 assert len(res.entries) == 3 

117 for entry in res.entries: 

118 assert entry.target_user_id == target.id 

119 assert entry.target_state == admin_pb2.NONVISIBLE_USER_STATE_BANNED 

120 

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

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

123 assert len(login) == 1 

124 assert len(views) == 2 

125 

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

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

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

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

130 

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

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

133 assert len(logged_in_view) == 1 

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

135 assert len(logged_out_view) == 1 

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

137 

138 

139def test_GetUser(db): 

140 super_user, super_token = generate_user(is_superuser=True) 

141 normal_user, normal_token = generate_user() 

142 

143 with real_admin_session(super_token) as api: 

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

145 assert res.user_id == normal_user.id 

146 assert res.username == normal_user.username 

147 

148 with real_admin_session(super_token) as api: 

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

150 

151 with real_admin_session(super_token) as api: 

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

153 assert res.user_id == normal_user.id 

154 assert res.username == normal_user.username 

155 

156 

157def test_GetUserDetails(db): 

158 super_user, super_token = generate_user(is_superuser=True) 

159 normal_user, normal_token = generate_user() 

160 

161 with real_admin_session(super_token) as api: 

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

163 assert res.user_id == normal_user.id 

164 assert res.username == normal_user.username 

165 assert res.email == normal_user.email 

166 assert res.gender == normal_user.gender 

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

168 assert not res.banned 

169 assert not res.deleted 

170 

171 with real_admin_session(super_token) as api: 

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

173 assert res.user_id == normal_user.id 

174 assert res.username == normal_user.username 

175 assert res.email == normal_user.email 

176 assert res.gender == normal_user.gender 

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

178 assert not res.banned 

179 assert not res.deleted 

180 

181 with real_admin_session(super_token) as api: 

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

183 assert res.user_id == normal_user.id 

184 assert res.username == normal_user.username 

185 assert res.email == normal_user.email 

186 assert res.gender == normal_user.gender 

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

188 assert not res.banned 

189 assert not res.deleted 

190 

191 

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

193 super_user, super_token = generate_user(is_superuser=True) 

194 normal_user, normal_token = generate_user() 

195 

196 with real_admin_session(super_token) as api: 

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

198 assert res.user_id == normal_user.id 

199 assert res.username == normal_user.username 

200 assert res.email == normal_user.email 

201 assert res.gender == "Machine" 

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

203 assert not res.banned 

204 assert not res.deleted 

205 

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

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

208 assert email.recipient == normal_user.email 

209 assert "Machine" in email.plain 

210 assert "Machine" in email.html 

211 

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

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

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

215 

216 

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

218 super_user, super_token = generate_user(is_superuser=True) 

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

220 

221 with real_admin_session(super_token) as api: 

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

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

224 

225 res = api.ChangeUserBirthdate( 

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

227 ) 

228 

229 assert res.user_id == normal_user.id 

230 assert res.username == normal_user.username 

231 assert res.email == normal_user.email 

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

233 assert res.gender == normal_user.gender 

234 assert not res.banned 

235 assert not res.deleted 

236 

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

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

239 assert email.recipient == normal_user.email 

240 assert "1990" in email.plain 

241 assert "1990" in email.html 

242 

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

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

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

246 

247 

248def test_BanUser(db): 

249 super_user, super_token = generate_user(is_superuser=True) 

250 normal_user, _ = generate_user() 

251 admin_note = "A good reason" 

252 

253 with real_admin_session(super_token) as api: 

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

255 assert res.user_id == normal_user.id 

256 assert res.username == normal_user.username 

257 assert res.email == normal_user.email 

258 assert res.gender == normal_user.gender 

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

260 assert res.banned 

261 assert not res.deleted 

262 assert len(res.admin_actions) == 1 

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

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

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

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

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

268 

269 

270def test_UnbanUser(db): 

271 super_user, super_token = generate_user(is_superuser=True) 

272 normal_user, _ = generate_user() 

273 admin_note = "A good reason" 

274 

275 with real_admin_session(super_token) as api: 

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

277 assert res.user_id == normal_user.id 

278 assert res.username == normal_user.username 

279 assert res.email == normal_user.email 

280 assert res.gender == normal_user.gender 

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

282 assert not res.banned 

283 assert not res.deleted 

284 assert len(res.admin_actions) == 1 

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

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

287 

288 

289def test_ShadowUser(db, moderator: Moderator): 

290 super_user, super_token = generate_user(is_superuser=True) 

291 surfer, surfer_token = generate_user() 

292 host, _ = generate_user() 

293 admin_note = "Spammer" 

294 

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

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

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

298 with requests_session(surfer_token) as api: 

299 host_request_id = api.CreateHostRequest( 

300 requests_pb2.CreateHostRequestReq( 

301 host_user_id=host.id, 

302 from_date=today_plus_2, 

303 to_date=today_plus_3, 

304 text=valid_request_text(), 

305 ) 

306 ).host_request_id 

307 moderator.approve_host_request(host_request_id) 

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, moderator: Moderator): 

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

347 admin_hidden_request_id = api.CreateHostRequest( 

348 requests_pb2.CreateHostRequestReq( 

349 host_user_id=host.id, 

350 from_date=today_plus_2, 

351 to_date=today_plus_3, 

352 text=valid_request_text(), 

353 ) 

354 ).host_request_id 

355 

356 moderator.hide_host_request(admin_hidden_request_id) 

357 with session_scope() as session: 

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

359 

360 with real_admin_session(super_token) as api: 

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

362 assert not res.shadowed 

363 assert len(res.admin_actions) == 1 

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

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

366 

367 with session_scope() as session: 

368 assert ( 

369 session.execute( 

370 select(ModerationState) 

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

372 .where(ModerationState.object_id == shadow_cascade_request_id) 

373 ) 

374 .scalar_one() 

375 .visibility 

376 == ModerationVisibility.visible 

377 ) 

378 assert ( 

379 session.execute( 

380 select(ModerationState) 

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

382 .where(ModerationState.object_id == admin_hidden_request_id) 

383 ) 

384 .scalar_one() 

385 .visibility 

386 == ModerationVisibility.hidden 

387 ) 

388 

389 

390def test_ShadowUser_blank_note(db): 

391 super_user, super_token = generate_user(is_superuser=True) 

392 normal_user, _ = generate_user() 

393 

394 with real_admin_session(super_token) as api: 

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

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

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

398 

399 

400def test_AddAdminNote(db): 

401 super_user, super_token = generate_user(is_superuser=True) 

402 normal_user, _ = generate_user() 

403 admin_note1 = "User reported strange behavior" 

404 admin_note2 = "Insert private information here" 

405 

406 with real_admin_session(super_token) as api: 

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

408 assert res.user_id == normal_user.id 

409 assert res.username == normal_user.username 

410 assert res.email == normal_user.email 

411 assert res.gender == normal_user.gender 

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

413 assert not res.banned 

414 assert not res.deleted 

415 assert len(res.admin_actions) == 1 

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

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

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

419 

420 with real_admin_session(super_token) as api: 

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

422 assert len(res.admin_actions) == 2 

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

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

425 

426 

427def test_AddAdminNote_blank(db): 

428 super_user, super_token = generate_user(is_superuser=True) 

429 normal_user, _ = generate_user() 

430 empty_admin_note = " \t \n " 

431 

432 with real_admin_session(super_token) as api: 

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

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

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

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

437 

438 

439def test_AddAdminNote_data(db): 

440 super_user, super_token = generate_user(is_superuser=True) 

441 normal_user, _ = generate_user() 

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

443 

444 with real_admin_session(super_token) as api: 

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

446 assert len(res.admin_actions) == 1 

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

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

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

450 

451 

452def test_AddAdminNote_both_note_and_data(db): 

453 super_user, super_token = generate_user(is_superuser=True) 

454 normal_user, _ = generate_user() 

455 

456 with real_admin_session(super_token) as api: 

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

458 api.AddAdminNote( 

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

460 ) 

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

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

463 

464 

465def test_AddAdminNote_neither(db): 

466 super_user, super_token = generate_user(is_superuser=True) 

467 normal_user, _ = generate_user() 

468 

469 with real_admin_session(super_token) as api: 

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

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

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

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

474 

475 

476def test_AddAdminNote_invalid_json(db): 

477 super_user, super_token = generate_user(is_superuser=True) 

478 normal_user, _ = generate_user() 

479 

480 with real_admin_session(super_token) as api: 

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

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

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

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

485 

486 

487def test_admin_content_reports(db): 

488 super_user, super_token = generate_user(is_superuser=True) 

489 normal_user, token = generate_user() 

490 bad_user1, _ = generate_user() 

491 bad_user2, _ = generate_user() 

492 

493 with reporting_session(token) as api: 

494 api.Report( 

495 reporting_pb2.ReportReq( 

496 reason="spam", 

497 description="r1", 

498 content_ref="comment/123", 

499 author_user=bad_user1.username, 

500 user_agent="n/a", 

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

502 ) 

503 ) 

504 api.Report( 

505 reporting_pb2.ReportReq( 

506 reason="spam", 

507 description="r2", 

508 content_ref="comment/124", 

509 author_user=bad_user2.username, 

510 user_agent="n/a", 

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

512 ) 

513 ) 

514 api.Report( 

515 reporting_pb2.ReportReq( 

516 reason="something else", 

517 description="r3", 

518 content_ref="page/321", 

519 author_user=bad_user1.username, 

520 user_agent="n/a", 

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

522 ) 

523 ) 

524 

525 with session_scope() as session: 

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

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

528 ) 

529 

530 with real_admin_session(super_token) as api: 

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

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

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

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

535 

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

537 rep = res.content_report 

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

539 assert rep.reporting_user_id == normal_user.id 

540 assert rep.author_user_id == bad_user2.id 

541 assert rep.reason == "spam" 

542 assert rep.description == "r2" 

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

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

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

546 

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

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

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

550 

551 

552def test_DeleteUser(db): 

553 super_user, super_token = generate_user(is_superuser=True) 

554 normal_user, normal_token = generate_user() 

555 

556 with real_admin_session(super_token) as api: 

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

558 assert res.user_id == normal_user.id 

559 assert res.username == normal_user.username 

560 assert res.email == normal_user.email 

561 assert res.gender == normal_user.gender 

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

563 assert not res.banned 

564 assert res.deleted 

565 

566 with real_admin_session(super_token) as api: 

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

568 assert res.user_id == normal_user.id 

569 assert res.username == normal_user.username 

570 assert res.email == normal_user.email 

571 assert res.gender == normal_user.gender 

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

573 assert not res.banned 

574 assert not res.deleted 

575 

576 

577def test_RecoverDeletedUser_after_user_initiated_deletion(db, push_collector: PushCollector): 

578 """ 

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

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

581 these fields to satisfy the undelete_nullity database constraint. 

582 """ 

583 super_user, super_token = generate_user(is_superuser=True) 

584 normal_user, normal_token = generate_user() 

585 user_id = normal_user.id 

586 

587 # User initiates account deletion 

588 with account_session(normal_token) as account: 

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

590 

591 # Get the deletion confirmation token 

592 with session_scope() as session: 

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

594 

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

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

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

598 

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

600 with session_scope() as session: 

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

602 assert user.deleted_at is not None 

603 assert user.undelete_token is not None 

604 assert user.undelete_until is not None 

605 

606 # Admin recovers the user 

607 with real_admin_session(super_token) as api: 

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

609 assert res.user_id == user_id 

610 assert not res.deleted 

611 

612 # Verify undelete fields are cleared 

613 with session_scope() as session: 

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

615 assert user.deleted_at is None 

616 assert user.undelete_token is None 

617 assert user.undelete_until is None 

618 

619 

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

621 with session_scope() as session: 

622 super_user, super_token = generate_user(is_superuser=True) 

623 normal_user, normal_token = generate_user() 

624 

625 assert ( 

626 session.execute( 

627 select(func.count()) 

628 .select_from(UserSession) 

629 .where(UserSession.is_api_key == True) 

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

631 ).scalar_one() 

632 == 0 

633 ) 

634 

635 with real_admin_session(super_token) as api: 

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

637 

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

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

640 

641 with session_scope() as session: 

642 token = session.execute( 

643 select(UserSession.token) 

644 .where(UserSession.is_valid) 

645 .where(UserSession.is_api_key == True) 

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

647 ).scalar_one() 

648 

649 assert token in email.plain 

650 assert token in email.html 

651 

652 assert email.recipient == normal_user.email 

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

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

655 assert unique_string in email.plain 

656 assert unique_string in email.html 

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

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

659 

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

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

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

663 

664 

665def test_GetChats(db): 

666 super_user, super_token = generate_user(is_superuser=True) 

667 normal_user, normal_token = generate_user() 

668 

669 with real_admin_session(super_token) as api: 

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

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

672 assert res.user.username == normal_user.username 

673 assert res.user.name == normal_user.name 

674 # New user should have no chats 

675 assert len(res.host_requests) == 0 

676 assert len(res.group_chats) == 0 

677 

678 

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

680 super_user, super_token = generate_user(is_superuser=True) 

681 normal_user, normal_token = generate_user() 

682 

683 with real_admin_session(super_token) as api: 

684 # can add a badge 

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

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

687 assert "swagster" in res.badges 

688 

689 # badge emails are disabled by default 

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

691 

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

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

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

695 

696 # can't add/edit special tags 

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

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

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

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

701 

702 # double add badge 

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

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

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

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

707 

708 # can remove badge 

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

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

711 assert "swagster" not in res.badges 

712 

713 # badge emails are disabled by default 

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

715 

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

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

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

719 

720 # not found on user 

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

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

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

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

725 

726 # not found in general 

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

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

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

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

731 

732 

733def test_DeleteEvent(db): 

734 super_user, super_token = generate_user(is_superuser=True) 

735 normal_user, normal_token = generate_user() 

736 

737 with session_scope() as session: 

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

739 

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

741 end_time = start_time + timedelta(hours=3) 

742 with events_session(normal_token) as api: 

743 res = api.CreateEvent( 

744 events_pb2.CreateEventReq( 

745 title="Dummy Title", 

746 content="Dummy content.", 

747 photo_key=None, 

748 location=events_pb2.EventLocation( 

749 address="Near Null Island", 

750 lat=0.1, 

751 lng=0.2, 

752 ), 

753 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time), 

754 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time), 

755 ) 

756 ) 

757 event_id = res.event_id 

758 assert not res.is_deleted 

759 

760 with session_scope() as session: 

761 with real_admin_session(super_token) as api: 

762 api.DeleteEvent( 

763 admin_pb2.DeleteEventReq( 

764 event_id=event_id, 

765 ) 

766 ) 

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

768 assert occurrence.is_deleted 

769 

770 

771def test_ListUserIds(db): 

772 super_user, super_token = generate_user(is_superuser=True) 

773 normal_user, normal_token = generate_user() 

774 

775 with real_admin_session(super_token) as api: 

776 res = api.ListUserIds( 

777 admin_pb2.ListUserIdsReq( 

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

779 end_time=Timestamp_from_datetime(now()), 

780 ) 

781 ) 

782 assert len(res.user_ids) == 2 

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

784 

785 with real_admin_session(super_token) as api: 

786 res = api.ListUserIds( 

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

788 ) 

789 assert res.user_ids == [] 

790 

791 

792def test_EditReferenceText(db): 

793 super_user, super_token = generate_user(is_superuser=True) 

794 test_new_text = "New Text" 

795 

796 user1, user1_token = generate_user() 

797 user2, user2_token = generate_user() 

798 make_friends(user1, user2) 

799 

800 with session_scope() as session: 

801 with references_session(user1_token) as api: 

802 reference = api.WriteFriendReference( 

803 references_pb2.WriteFriendReferenceReq( 

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

805 ) 

806 ) 

807 

808 with real_admin_session(super_token) as admin_api: 

809 admin_api.EditReferenceText( 

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

811 ) 

812 

813 session.expire_all() 

814 

815 modified_reference = session.execute( 

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

817 ).scalar_one() 

818 assert modified_reference.text == test_new_text 

819 

820 

821def test_DeleteReference_deprecated(db): 

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

823 super_user, super_token = generate_user(is_superuser=True) 

824 

825 user1, user1_token = generate_user() 

826 user2, user2_token = generate_user() 

827 make_friends(user1, user2) 

828 

829 with references_session(user1_token) as api: 

830 reference = api.WriteFriendReference( 

831 references_pb2.WriteFriendReferenceReq( 

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

833 ) 

834 ) 

835 

836 with real_admin_session(super_token) as admin_api: 

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

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

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

840 

841 

842def test_GetUserReferences(db): 

843 super_user, super_token = generate_user(is_superuser=True) 

844 

845 user1, user1_token = generate_user() 

846 user2, user2_token = generate_user() 

847 user3, user3_token = generate_user() 

848 make_friends(user1, user2) 

849 make_friends(user1, user3) 

850 make_friends(user2, user3) 

851 

852 # user1 writes reference about user2 

853 with references_session(user1_token) as api: 

854 ref1 = api.WriteFriendReference( 

855 references_pb2.WriteFriendReferenceReq( 

856 to_user_id=user2.id, 

857 text="Reference from user1 to user2", 

858 private_text="", 

859 was_appropriate=True, 

860 rating=1, 

861 ) 

862 ) 

863 

864 # user2 writes reference about user1 

865 with references_session(user2_token) as api: 

866 ref2 = api.WriteFriendReference( 

867 references_pb2.WriteFriendReferenceReq( 

868 to_user_id=user1.id, 

869 text="Reference from user2 to user1", 

870 private_text="Private note", 

871 was_appropriate=True, 

872 rating=0.8, 

873 ) 

874 ) 

875 

876 # user3 writes reference about user1 

877 with references_session(user3_token) as api: 

878 ref3 = api.WriteFriendReference( 

879 references_pb2.WriteFriendReferenceReq( 

880 to_user_id=user1.id, 

881 text="Reference from user3 to user1", 

882 private_text="", 

883 was_appropriate=False, 

884 rating=0.5, 

885 ) 

886 ) 

887 

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

889 with real_admin_session(super_token) as admin_api: 

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

891 

892 # user1 wrote 1 reference 

893 assert len(res.references_from) == 1 

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

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

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

897 assert res.references_from[0].from_user.user_id == user1.id 

898 assert res.references_from[0].from_user.username == user1.username 

899 assert res.references_from[0].to_user.user_id == user2.id 

900 assert res.references_from[0].to_user.username == user2.username 

901 assert res.references_from[0].reference_type == "friend" 

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

903 # freshly written content starts out shadowed 

904 assert res.references_from[0].moderation_visibility == "shadowed" 

905 assert res.references_from[0].host_request_id == 0 

906 assert res.references_from[0].hosting_city == "" 

907 assert res.references_from[0].from_date == "" 

908 assert res.references_from[0].to_date == "" 

909 assert res.references_from[0].status == "" 

910 

911 # user1 received 2 references 

912 assert len(res.references_to) == 2 

913 # Ordered by id descending, so ref3 comes first 

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

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

916 

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

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

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

920 

921 

922def test_GetUserReferences_host_request(db): 

923 super_user, super_token = generate_user(is_superuser=True) 

924 

925 user1, _ = generate_user() 

926 user2, _ = generate_user() 

927 

928 with session_scope() as session: 

929 surfed_ref_id, host_request_id = create_host_reference(session, user1.id, user2.id, timedelta(days=2)) 

930 hosted_ref_id, _ = create_host_reference( 

931 session, user2.id, user1.id, timedelta(days=2), host_request_id=host_request_id 

932 ) 

933 host_request = session.execute( 

934 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

935 ).scalar_one() 

936 from_date = date_to_api(host_request.from_date) 

937 to_date = date_to_api(host_request.to_date) 

938 

939 with real_admin_session(super_token) as admin_api: 

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

941 

942 assert len(res.references_from) == 1 

943 surfed_ref = res.references_from[0] 

944 assert surfed_ref.reference_id == surfed_ref_id 

945 assert surfed_ref.reference_type == "surfed" 

946 assert surfed_ref.from_user.user_id == user1.id 

947 assert surfed_ref.from_user.username == user1.username 

948 assert surfed_ref.to_user.user_id == user2.id 

949 assert surfed_ref.to_user.username == user2.username 

950 assert surfed_ref.moderation_visibility == "visible" 

951 assert surfed_ref.host_request_id == host_request_id 

952 assert surfed_ref.hosting_city == "Test City" 

953 assert surfed_ref.from_date == from_date 

954 assert surfed_ref.to_date == to_date 

955 assert surfed_ref.status == "confirmed" 

956 

957 assert len(res.references_to) == 1 

958 hosted_ref = res.references_to[0] 

959 assert hosted_ref.reference_id == hosted_ref_id 

960 assert hosted_ref.reference_type == "hosted" 

961 assert hosted_ref.from_user.user_id == user2.id 

962 assert hosted_ref.to_user.user_id == user1.id 

963 assert hosted_ref.host_request_id == host_request_id 

964 assert hosted_ref.hosting_city == "Test City" 

965 assert hosted_ref.from_date == from_date 

966 assert hosted_ref.to_date == to_date 

967 assert hosted_ref.status == "confirmed" 

968 

969 

970def test_GetUserReferences_not_found(db): 

971 super_user, super_token = generate_user(is_superuser=True) 

972 

973 with real_admin_session(super_token) as admin_api: 

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

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

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

977 

978 

979def test_GetFriendRequests(db): 

980 super_user, super_token = generate_user(is_superuser=True) 

981 

982 user1, _ = generate_user() 

983 user2, _ = generate_user() 

984 user3, _ = generate_user() 

985 user4, _ = generate_user() 

986 

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

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

989 with session_scope() as session: 

990 mod_state = ModerationState( 

991 object_type=ModerationObjectType.friend_request, 

992 object_id=0, 

993 visibility=visibility, 

994 ) 

995 session.add(mod_state) 

996 session.flush() 

997 rel = FriendRelationship( 

998 from_user_id=from_user_id, 

999 to_user_id=to_user_id, 

1000 status=status, 

1001 moderation_state_id=mod_state.id, 

1002 time_responded=time_responded, 

1003 ) 

1004 session.add(rel) 

1005 session.flush() 

1006 mod_state.object_id = rel.id 

1007 

1008 # user1 -> user2: pending, shadowed 

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

1010 # user1 -> user3: accepted, visible 

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

1012 # user4 -> user1: rejected, visible 

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

1014 

1015 with real_admin_session(super_token) as admin_api: 

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

1017 

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

1019 assert len(res.sent) == 2 

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

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

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

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

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

1025 

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

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

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

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

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

1031 

1032 # user1 received one: from user4 (rejected) 

1033 assert len(res.received) == 1 

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

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

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

1037 

1038 

1039def test_GetFriendRequests_not_found(db): 

1040 super_user, super_token = generate_user(is_superuser=True) 

1041 

1042 with real_admin_session(super_token) as admin_api: 

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

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

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

1046 

1047 

1048def test_GetUserBlocks(db): 

1049 super_user, super_token = generate_user(is_superuser=True) 

1050 

1051 user1, token1 = generate_user() 

1052 user2, _ = generate_user() 

1053 user3, token3 = generate_user() 

1054 user4, token4 = generate_user() 

1055 

1056 with blocking_session(token1) as user_blocks: 

1057 user_blocks.BlockUser(blocking_pb2.BlockUserReq(username=user2.username)) 

1058 

1059 with blocking_session(token3) as user_blocks: 

1060 user_blocks.BlockUser(blocking_pb2.BlockUserReq(username=user1.username)) 

1061 

1062 with blocking_session(token4) as user_blocks: 

1063 user_blocks.BlockUser(blocking_pb2.BlockUserReq(username=user1.username)) 

1064 

1065 with real_admin_session(super_token) as admin_api: 

1066 res = admin_api.GetUserBlocks(admin_pb2.GetUserBlocksReq(user=user1.username)) 

1067 

1068 assert len(res.blocked_users) == 1 

1069 assert res.blocked_users[0].user.user_id == user2.id 

1070 assert res.blocked_users[0].user.username == user2.username 

1071 assert res.blocked_users[0].HasField("time_blocked") 

1072 

1073 # most recently blocked first 

1074 assert [block.user.user_id for block in res.blocking_users] == [user4.id, user3.id] 

1075 

1076 with real_admin_session(super_token) as admin_api: 

1077 res = admin_api.GetUserBlocks(admin_pb2.GetUserBlocksReq(user=user2.username)) 

1078 

1079 assert len(res.blocked_users) == 0 

1080 assert [block.user.user_id for block in res.blocking_users] == [user1.id] 

1081 

1082 

1083def test_GetUserBlocks_not_found(db): 

1084 super_user, super_token = generate_user(is_superuser=True) 

1085 

1086 with real_admin_session(super_token) as admin_api: 

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

1088 admin_api.GetUserBlocks(admin_pb2.GetUserBlocksReq(user="nonexistent")) 

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

1090 

1091 

1092def test_AddUsersToModerationUserList(db): 

1093 super_user, super_token = generate_user(is_superuser=True) 

1094 user1, _ = generate_user() 

1095 user2, _ = generate_user() 

1096 user3, _ = generate_user() 

1097 user4, _ = generate_user() 

1098 user5, _ = generate_user() 

1099 moderation_list_id = add_users_to_new_moderation_list([user1]) 

1100 

1101 with session_scope() as session: 

1102 with real_admin_session(super_token) as api: 

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

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

1105 api.AddUsersToModerationUserList( 

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

1107 ) 

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

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

1110 

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

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

1113 api.AddUsersToModerationUserList( 

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

1115 ) 

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

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

1118 

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

1120 res = api.AddUsersToModerationUserList( 

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

1122 ) 

1123 assert res.moderation_list_id > 0 

1124 with session_scope() as session: 

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

1126 assert moderation_user_list is not None 

1127 assert len(moderation_user_list.users) == 3 

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

1129 

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

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

1132 assert len(listRes.moderation_lists) == 1 

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

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

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

1136 

1137 # Test user can be in multiple moderation lists 

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

1139 assert len(listRes3.moderation_lists) == 2 

1140 

1141 # Test adding users to an existing moderation list 

1142 res2 = api.AddUsersToModerationUserList( 

1143 admin_pb2.AddUsersToModerationUserListReq( 

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

1145 ), 

1146 ) 

1147 assert res2.moderation_list_id == moderation_list_id 

1148 with session_scope() as session: 

1149 moderation_user_list = session.get_one(ModerationUserList, moderation_list_id) 

1150 assert len(moderation_user_list.users) == 3 

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

1152 

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

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

1155 assert len(listRes2.moderation_lists) == 1 

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

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

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

1159 

1160 

1161def test_RemoveUserFromModerationUserList(db): 

1162 super_user, super_token = generate_user(is_superuser=True) 

1163 user1, _ = generate_user() 

1164 user2, _ = generate_user() 

1165 user3, _ = generate_user() 

1166 moderation_list_id = add_users_to_new_moderation_list([user1, user2]) 

1167 

1168 with real_admin_session(super_token) as api: 

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

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

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

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

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

1174 

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

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

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

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

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

1180 

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

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

1183 api.RemoveUserFromModerationUserList( 

1184 admin_pb2.RemoveUserFromModerationUserListReq( 

1185 user=user3.username, moderation_list_id=moderation_list_id 

1186 ) 

1187 ) 

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

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

1190 

1191 # Test successful removal 

1192 api.RemoveUserFromModerationUserList( 

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

1194 ) 

1195 with session_scope() as session: 

1196 moderation_user_list = session.get_one(ModerationUserList, moderation_list_id) 

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

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

1199 

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

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

1202 assert len(listRes.moderation_lists) == 0 

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

1204 assert len(listRes2.moderation_lists) == 1 

1205 

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

1207 api.RemoveUserFromModerationUserList( 

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

1209 ) 

1210 with session_scope() as session: 

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

1212 

1213 

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

1215 super_user, super_token = generate_user(is_superuser=True) 

1216 

1217 user, token = generate_user() 

1218 user_id = user.id 

1219 

1220 with real_admin_session(super_token) as admin_api: 

1221 url = admin_api.CreateAccountDeletionLink( 

1222 admin_pb2.CreateAccountDeletionLinkReq(user=user.username) 

1223 ).account_deletion_confirm_url 

1224 

1225 assert push_collector.count_for_user(user_id) == 0 

1226 

1227 with session_scope() as session: 

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

1229 token = token_o.token 

1230 assert token_o.user.id == user_id 

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

1232 

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

1234 auth_api.ConfirmDeleteAccount( 

1235 auth_pb2.ConfirmDeleteAccountReq( 

1236 token=token, 

1237 ) 

1238 ) 

1239 

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

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

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

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

1244 

1245 

1246def test_AccessStats(db): 

1247 super_user, super_token = generate_user(is_superuser=True) 

1248 normal_user, normal_token = generate_user() 

1249 

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

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

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

1253 # crash the proto string assignment. 

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

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

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

1257 with session_scope() as session: 

1258 session.add( 

1259 UserActivity( 

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

1261 ) 

1262 ) 

1263 session.add( 

1264 UserActivity( 

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

1266 ) 

1267 ) 

1268 session.add( 

1269 UserActivity( 

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

1271 ) 

1272 ) 

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

1274 

1275 with real_admin_session(super_token) as api: 

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

1277 

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

1279 assert "1.2.3.4" in by_ip 

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

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

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

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

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

1285 assert "" in by_ip 

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

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

1288 assert "9.9.9.9" not in by_ip 

1289 

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

1291 with real_admin_session(super_token) as api: 

1292 res = api.AccessStats( 

1293 admin_pb2.AccessStatsReq( 

1294 user=normal_user.username, 

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

1296 end_time=Timestamp_from_datetime(now()), 

1297 ) 

1298 ) 

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

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

1301 

1302 

1303def test_SetLastDonated(db): 

1304 super_user, super_token = generate_user(is_superuser=True) 

1305 normal_user, normal_token = generate_user(last_donated=None) 

1306 

1307 with real_admin_session(super_token) as api: 

1308 # user starts with no last_donated 

1309 with session_scope() as session: 

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

1311 assert user.last_donated is None 

1312 

1313 # can set last_donated 

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

1315 res = api.SetLastDonated( 

1316 admin_pb2.SetLastDonatedReq( 

1317 user=normal_user.username, 

1318 last_donated=Timestamp_from_datetime(donation_time), 

1319 ) 

1320 ) 

1321 

1322 with session_scope() as session: 

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

1324 assert user.last_donated is not None 

1325 # check timestamp is close (within a second) 

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

1327 

1328 # can clear last_donated by not setting the field 

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

1330 

1331 with session_scope() as session: 

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

1333 assert user.last_donated is None 

1334 

1335 # user not found 

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

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

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

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

1340 

1341 

1342def test_admin_actions_level(db): 

1343 super_user, super_token = generate_user(is_superuser=True) 

1344 normal_user, _ = generate_user() 

1345 

1346 with real_admin_session(super_token) as api: 

1347 # Default level is NORMAL 

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

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

1350 

1351 # Explicitly set to DEBUG 

1352 res = api.AddAdminNote( 

1353 admin_pb2.AddAdminNoteReq( 

1354 user=normal_user.username, 

1355 admin_note="debug note", 

1356 level=admin_pb2.ADMIN_ACTION_LEVEL_DEBUG, 

1357 ) 

1358 ) 

1359 assert len(res.admin_actions) == 2 

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

1361 

1362 # Explicitly set to HIGH 

1363 res = api.AddAdminNote( 

1364 admin_pb2.AddAdminNoteReq( 

1365 user=normal_user.username, 

1366 admin_note="high note", 

1367 level=admin_pb2.ADMIN_ACTION_LEVEL_HIGH, 

1368 ) 

1369 ) 

1370 assert len(res.admin_actions) == 3 

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

1372 

1373 # Explicitly set to TRACE 

1374 res = api.AddAdminNote( 

1375 admin_pb2.AddAdminNoteReq( 

1376 user=normal_user.username, 

1377 admin_note="trace note", 

1378 level=admin_pb2.ADMIN_ACTION_LEVEL_TRACE, 

1379 ) 

1380 ) 

1381 assert len(res.admin_actions) == 4 

1382 assert res.admin_actions[3].level == admin_pb2.ADMIN_ACTION_LEVEL_TRACE 

1383 

1384 

1385def test_admin_actions_on_mutations(db, push_collector: PushCollector): 

1386 super_user, super_token = generate_user(is_superuser=True) 

1387 normal_user, _ = generate_user() 

1388 

1389 original_gender = normal_user.gender 

1390 original_birthdate = normal_user.birthdate 

1391 

1392 with real_admin_session(super_token) as api: 

1393 # ChangeUserGender 

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

1395 assert any( 

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

1397 for a in res.admin_actions 

1398 ) 

1399 

1400 # ChangeUserBirthdate 

1401 res = api.ChangeUserBirthdate( 

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

1403 ) 

1404 assert any( 

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

1406 for a in res.admin_actions 

1407 ) 

1408 

1409 # SetPassportSexGenderException 

1410 res = api.SetPassportSexGenderException( 

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

1412 ) 

1413 assert any( 

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

1415 for a in res.admin_actions 

1416 ) 

1417 

1418 # SendModNote with notify 

1419 res = api.SendModNote( 

1420 admin_pb2.SendModNoteReq( 

1421 user=normal_user.username, 

1422 content="Please update your profile", 

1423 internal_id="test1", 

1424 notification=admin_pb2.MOD_NOTE_NOTIFICATION_NOTIFY, 

1425 ) 

1426 ) 

1427 assert any( 

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

1429 for a in res.admin_actions 

1430 ) 

1431 

1432 # SendModNote without notifying 

1433 res = api.SendModNote( 

1434 admin_pb2.SendModNoteReq( 

1435 user=normal_user.username, 

1436 content="Silent note", 

1437 internal_id="test2", 

1438 notification=admin_pb2.MOD_NOTE_NOTIFICATION_NONE, 

1439 ) 

1440 ) 

1441 assert any( 

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

1443 ) 

1444 

1445 # SendModNote including the note text in the email 

1446 res = api.SendModNote( 

1447 admin_pb2.SendModNoteReq( 

1448 user=normal_user.username, 

1449 content="Emailed note", 

1450 internal_id="test3", 

1451 notification=admin_pb2.MOD_NOTE_NOTIFICATION_NOTIFY_WITH_CONTENT, 

1452 ) 

1453 ) 

1454 assert any( 

1455 a.action_type == "send_mod_note" and a.note == "Notify user: Yes, including the note text\n\nEmailed note" 

1456 for a in res.admin_actions 

1457 ) 

1458 

1459 # DeleteUser 

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

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

1462 assert any( 

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

1464 ) 

1465 

1466 # RecoverDeletedUser 

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

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

1469 

1470 # MarkUserNeedsLocationUpdate 

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

1472 assert any( 

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

1474 for a in res.admin_actions 

1475 ) 

1476 

1477 # SetLastDonated 

1478 res = api.SetLastDonated( 

1479 admin_pb2.SetLastDonatedReq( 

1480 user=normal_user.username, 

1481 last_donated=Timestamp_from_datetime(now()), 

1482 ) 

1483 ) 

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

1485 

1486 

1487def test_create_admin_tag(db): 

1488 super_user, super_token = generate_user(is_superuser=True) 

1489 

1490 with real_admin_session(super_token) as api: 

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

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

1493 assert res.admin_tag_id > 0 

1494 

1495 

1496def test_create_admin_tag_duplicate(db): 

1497 super_user, super_token = generate_user(is_superuser=True) 

1498 

1499 with real_admin_session(super_token) as api: 

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

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

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

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

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

1505 

1506 

1507def test_create_admin_tag_empty(db): 

1508 super_user, super_token = generate_user(is_superuser=True) 

1509 

1510 with real_admin_session(super_token) as api: 

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

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

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

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

1515 

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

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

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

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

1520 

1521 

1522def test_list_admin_tags(db): 

1523 super_user, super_token = generate_user(is_superuser=True) 

1524 

1525 with real_admin_session(super_token) as api: 

1526 # Empty initially 

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

1528 assert len(res.tags) == 0 

1529 

1530 # Add some tags 

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

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

1533 

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

1535 assert len(res.tags) == 2 

1536 # Ordered alphabetically 

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

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

1539 

1540 

1541def test_add_admin_tag_to_user(db): 

1542 super_user, super_token = generate_user(is_superuser=True) 

1543 normal_user, _ = generate_user() 

1544 

1545 with real_admin_session(super_token) as api: 

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

1547 

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

1549 assert "vip" in res.admin_tags 

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

1551 

1552 

1553def test_add_admin_tag_to_user_duplicate(db): 

1554 super_user, super_token = generate_user(is_superuser=True) 

1555 normal_user, _ = generate_user() 

1556 

1557 with real_admin_session(super_token) as api: 

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

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

1560 

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

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

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

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

1565 

1566 

1567def test_add_admin_tag_to_user_tag_not_found(db): 

1568 super_user, super_token = generate_user(is_superuser=True) 

1569 normal_user, _ = generate_user() 

1570 

1571 with real_admin_session(super_token) as api: 

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

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

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

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

1576 

1577 

1578def test_remove_admin_tag_from_user(db): 

1579 super_user, super_token = generate_user(is_superuser=True) 

1580 normal_user, _ = generate_user() 

1581 

1582 with real_admin_session(super_token) as api: 

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

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

1585 

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

1587 assert "vip" not in res.admin_tags 

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

1589 

1590 

1591def test_remove_admin_tag_from_user_not_assigned(db): 

1592 super_user, super_token = generate_user(is_superuser=True) 

1593 normal_user, _ = generate_user() 

1594 

1595 with real_admin_session(super_token) as api: 

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

1597 

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

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

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

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

1602 

1603 

1604def test_search_users_by_admin_tag(db): 

1605 super_user, super_token = generate_user(is_superuser=True) 

1606 user1, _ = generate_user() 

1607 user2, _ = generate_user() 

1608 user3, _ = generate_user() 

1609 

1610 with real_admin_session(super_token) as api: 

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

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

1613 

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

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

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

1617 

1618 # Search for users with "vip" tag 

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

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

1621 assert user1.id in user_ids 

1622 assert user2.id in user_ids 

1623 assert user3.id not in user_ids 

1624 

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

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

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

1628 assert user2.id in user_ids 

1629 assert user1.id not in user_ids 

1630 

1631 # Search for non-existent tag returns no results 

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

1633 assert len(res.users) == 0 

1634 

1635 

1636def test_search_users_by_admin_note(db): 

1637 super_user, super_token = generate_user(is_superuser=True) 

1638 user1, _ = generate_user() 

1639 user2, _ = generate_user() 

1640 

1641 with real_admin_session(super_token) as api: 

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

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

1644 

1645 # Search by admin action log content (ilike) 

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

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

1648 assert user1.id in user_ids 

1649 assert user2.id not in user_ids 

1650 

1651 

1652def test_ListAdminActions_empty(db): 

1653 super_user, super_token = generate_user(is_superuser=True) 

1654 

1655 with real_admin_session(super_token) as api: 

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

1657 assert len(res.admin_actions) == 0 

1658 assert res.next_page_token == "" 

1659 

1660 

1661def test_ListAdminActions_returns_newest_first_with_target_info(db): 

1662 super_user, super_token = generate_user(is_superuser=True) 

1663 user1, _ = generate_user() 

1664 user2, _ = generate_user() 

1665 

1666 with real_admin_session(super_token) as api: 

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

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

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

1670 

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

1672 

1673 assert len(res.admin_actions) == 3 

1674 # Newest first 

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

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

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

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

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

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

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

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

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

1684 

1685 

1686def test_ListAdminActions_filter_by_admin_and_target(db): 

1687 super1, super1_token = generate_user(is_superuser=True) 

1688 super2, super2_token = generate_user(is_superuser=True) 

1689 user1, _ = generate_user() 

1690 user2, _ = generate_user() 

1691 

1692 with real_admin_session(super1_token) as api: 

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

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

1695 with real_admin_session(super2_token) as api: 

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

1697 

1698 with real_admin_session(super1_token) as api: 

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

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

1701 

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

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

1704 

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

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

1707 

1708 

1709def test_ListAdminActions_pagination(db): 

1710 super_user, super_token = generate_user(is_superuser=True) 

1711 user, _ = generate_user() 

1712 

1713 with real_admin_session(super_token) as api: 

1714 for i in range(3): 

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

1716 

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

1718 assert len(res.admin_actions) == 2 

1719 assert res.next_page_token != "" 

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

1721 

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

1723 assert len(res2.admin_actions) == 1 

1724 assert res2.next_page_token == "" 

1725 

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

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

1728 

1729 

1730def test_ListUserUploads(db): 

1731 super_user, super_token = generate_user(is_superuser=True) 

1732 user, _ = generate_user(complete_profile=False) 

1733 other_user, _ = generate_user() 

1734 

1735 with session_scope() as session: 

1736 for i in range(3): 

1737 session.add( 

1738 Upload( 

1739 key=f"key{i}", 

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

1741 creator_user_id=user.id, 

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

1743 ) 

1744 ) 

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

1746 

1747 with real_admin_session(super_token) as api: 

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

1749 

1750 assert len(res.uploads) == 3 

1751 assert res.next_page_token == "" 

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

1753 

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

1755 assert upload0.credit == "credit 0" 

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

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

1758 assert upload0.HasField("created") 

1759 

1760 

1761def test_ListUserUploads_metadata(db): 

1762 super_user, super_token = generate_user(is_superuser=True) 

1763 user, _ = generate_user(complete_profile=False) 

1764 

1765 with session_scope() as session: 

1766 session.add( 

1767 Upload( 

1768 key="with_metadata", 

1769 filename="photo.jpg", 

1770 creator_user_id=user.id, 

1771 metadata_exif=b"\x01\x02\x03", 

1772 metadata_parsed={"Image Make": "Canon", "EXIF LensModel": "50mm"}, 

1773 original_filename="IMG_1234.HEIC", 

1774 original_format="heif", 

1775 original_size=123456, 

1776 original_width=4032, 

1777 original_height=3024, 

1778 ) 

1779 ) 

1780 session.add(Upload(key="without_metadata", filename="old.jpg", creator_user_id=user.id)) 

1781 

1782 with real_admin_session(super_token) as api: 

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

1784 

1785 uploads = {u.key: u for u in res.uploads} 

1786 assert uploads["without_metadata"].metadata == admin_pb2.UploadMetadata() 

1787 

1788 metadata = uploads["with_metadata"].metadata 

1789 assert json.loads(metadata.parsed_json) == {"Image Make": "Canon", "EXIF LensModel": "50mm"} 

1790 assert metadata.parse_error == "" 

1791 assert metadata.original_filename == "IMG_1234.HEIC" 

1792 assert metadata.original_format == "heif" 

1793 assert metadata.original_size == 123456 

1794 assert metadata.original_width == 4032 

1795 assert metadata.original_height == 3024 

1796 

1797 

1798def test_ListUserUploads_pagination(db): 

1799 super_user, super_token = generate_user(is_superuser=True) 

1800 user, _ = generate_user(complete_profile=False) 

1801 

1802 with session_scope() as session: 

1803 for i in range(3): 

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

1805 

1806 with real_admin_session(super_token) as api: 

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

1808 assert len(res.uploads) == 2 

1809 assert res.next_page_token != "" 

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

1811 

1812 res2 = api.ListUserUploads( 

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

1814 ) 

1815 assert len(res2.uploads) == 1 

1816 assert res2.next_page_token == "" 

1817 

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

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

1820 

1821 

1822def test_ListUserUploads_uses(db): 

1823 super_user, super_token = generate_user(is_superuser=True) 

1824 user, _ = generate_user(complete_profile=False) 

1825 

1826 with session_scope() as session: 

1827 session.add(Upload(key="used_key", filename="used.jpg", creator_user_id=user.id)) 

1828 session.add(Upload(key="unused_key", filename="unused.jpg", creator_user_id=user.id)) 

1829 gallery = PhotoGallery(owner_user_id=user.id) 

1830 session.add(gallery) 

1831 session.flush() 

1832 session.add(PhotoGalleryItem(gallery_id=gallery.id, upload_key="used_key", position=1.0)) 

1833 

1834 with real_admin_session(super_token) as api: 

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

1836 

1837 uploads = {u.key: u for u in res.uploads} 

1838 assert list(uploads["unused_key"].uses) == [] 

1839 

1840 used_uses = uploads["used_key"].uses 

1841 assert len(used_uses) == 1 

1842 assert used_uses[0].type == admin_pb2.UPLOAD_USE_TYPE_PROFILE_GALLERY_PHOTO_AVATAR 

1843 assert used_uses[0].is_current 

1844 assert used_uses[0].user_id == user.id 

1845 

1846 

1847def test_ListUserUploads_not_found(db): 

1848 super_user, super_token = generate_user(is_superuser=True) 

1849 

1850 with real_admin_session(super_token) as api: 

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

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

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

1854 

1855 

1856# community invite feature tested in test_events.py 

1857# SendBlogPostNotification tested in test_notifications.py 

1858# MarkUserNeedsLocationUpdate tested in test_jail.py 

1859 

1860 

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

1862 return { 

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

1864 "createdAt": created_at, 

1865 "runtimeVersion": fingerprint, 

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

1867 "assets": [], 

1868 "metadata": {}, 

1869 "extra": {}, 

1870 } 

1871 

1872 

1873def _ota_signed_multipart(manifest): 

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

1875 boundary = "COUCHERS_OTA_BOUNDARY" 

1876 

1877 def part(name, body, content_type): 

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

1879 

1880 body = ( 

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

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

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

1884 ) 

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

1886 

1887 

1888def _patch_ota_cdn(manifests): 

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

1890 def fake(url): 

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

1892 if version not in manifests: 

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

1894 return _ota_signed_multipart(manifests[version]) 

1895 

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

1897 

1898 

1899def test_CreateOTAPackage(db): 

1900 super_user, super_token = generate_user(is_superuser=True) 

1901 

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

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

1904 res = api.CreateOTAPackage( 

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

1906 ) 

1907 

1908 assert res.platform == admin_pb2.OTA_PLATFORM_IOS 

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

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

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

1912 assert res.banned is False 

1913 assert res.live is True 

1914 assert res.creator_user_id == super_user.id 

1915 

1916 

1917def test_CreateOTAPackage_invalid(db): 

1918 _, super_token = generate_user(is_superuser=True) 

1919 

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

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

1922 # missing version 

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

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

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

1926 

1927 # nothing published at this version 

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

1929 api.CreateOTAPackage( 

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

1931 ) 

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

1933 

1934 # manifest present but missing required fields 

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

1936 api.CreateOTAPackage( 

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

1938 ) 

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

1940 

1941 

1942def test_CreateOTAPackage_rejects_duplicate_version(db): 

1943 _, super_token = generate_user(is_superuser=True) 

1944 

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

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

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

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

1949 api.CreateOTAPackage( 

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

1951 ) 

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

1953 

1954 

1955def test_ListOTAPackages(db): 

1956 _, super_token = generate_user(is_superuser=True) 

1957 

1958 manifests = { 

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

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

1961 "v1.3.2.android": _ota_manifest( 

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

1963 ), 

1964 } 

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

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

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

1968 api.CreateOTAPackage( 

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

1970 ) 

1971 

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

1973 # newest (by manifest createdAt) first 

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

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

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

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

1978 

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

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

1981 

1982 

1983def test_BanOTAPackage(db): 

1984 super_user, super_token = generate_user(is_superuser=True) 

1985 

1986 manifests = { 

1987 "v1.3.1.good": _ota_manifest( 

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

1989 ), 

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

1991 } 

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

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

1994 second = api.CreateOTAPackage( 

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

1996 ) 

1997 assert second.live is True 

1998 

1999 banned = api.BanOTAPackage( 

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

2001 ) 

2002 assert banned.banned is True 

2003 assert banned.banned_reason == "bad bundle" 

2004 assert banned.banned_by_user_id == super_user.id 

2005 assert banned.live is False 

2006 

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

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

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

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

2011 

2012 # banned packages are excluded by default 

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

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

2015 

2016 

2017def test_BanOTAPackage_requires_reason(db): 

2018 _, super_token = generate_user(is_superuser=True) 

2019 

2020 manifests = { 

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

2022 } 

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

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

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

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

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

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

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

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

2031 

2032 

2033def test_BanOTAPackage_not_found(db): 

2034 _, super_token = generate_user(is_superuser=True) 

2035 

2036 with real_admin_session(super_token) as api: 

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

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

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