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

1195 statements  

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

1import json 

2from datetime import UTC, date, datetime, timedelta 

3from unittest.mock import patch 

4 

5import grpc 

6import pytest 

7from google.protobuf import wrappers_pb2 

8from sqlalchemy import select, update 

9from sqlalchemy.sql import func 

10 

11from couchers.db import session_scope 

12from couchers.models import ( 

13 AccountDeletionToken, 

14 ContentReport, 

15 EventOccurrence, 

16 FriendRelationship, 

17 FriendStatus, 

18 HostRequest, 

19 ModerationObjectType, 

20 ModerationState, 

21 ModerationUserList, 

22 ModerationVisibility, 

23 NonvisibleUserAccess, 

24 NonvisibleUserAccessType, 

25 NonvisibleUserState, 

26 PhotoGallery, 

27 PhotoGalleryItem, 

28 Reference, 

29 Upload, 

30 User, 

31 UserActivity, 

32 UserSession, 

33) 

34from couchers.proto import ( 

35 account_pb2, 

36 admin_pb2, 

37 auth_pb2, 

38 blocking_pb2, 

39 references_pb2, 

40 reporting_pb2, 

41 requests_pb2, 

42) 

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

44from tests.fixtures.api import events as events_api 

45from tests.fixtures.db import ( 

46 add_users_to_new_moderation_list, 

47 backdate_conversations, 

48 generate_user, 

49 make_friends, 

50) 

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

52from tests.fixtures.sessions import ( 

53 account_session, 

54 auth_api_session, 

55 blocking_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_SetModerationAutoApproveDeadline(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 res = api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.username)) 

396 assert not res.HasField("moderation_auto_approve_seconds") 

397 

398 res = api.SetModerationAutoApproveDeadline( 

399 admin_pb2.SetModerationAutoApproveDeadlineReq( 

400 user=normal_user.username, auto_approve_seconds=wrappers_pb2.Int64Value(value=0) 

401 ) 

402 ) 

403 assert res.moderation_auto_approve_seconds.value == 0 

404 

405 res = api.SetModerationAutoApproveDeadline( 

406 admin_pb2.SetModerationAutoApproveDeadlineReq( 

407 user=normal_user.username, auto_approve_seconds=wrappers_pb2.Int64Value(value=48 * 3600) 

408 ) 

409 ) 

410 assert res.moderation_auto_approve_seconds.value == 48 * 3600 

411 

412 res = api.SetModerationAutoApproveDeadline( 

413 admin_pb2.SetModerationAutoApproveDeadlineReq(user=normal_user.username) 

414 ) 

415 assert not res.HasField("moderation_auto_approve_seconds") 

416 

417 for seconds in [-1, 48 * 3600 + 1, 2**63 - 1]: 

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

419 api.SetModerationAutoApproveDeadline( 

420 admin_pb2.SetModerationAutoApproveDeadlineReq( 

421 user=normal_user.username, auto_approve_seconds=wrappers_pb2.Int64Value(value=seconds) 

422 ) 

423 ) 

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

425 

426 assert [(a.action_type, a.note) for a in res.admin_actions] == [ 

427 ("set_moderation_auto_approve_deadline", "Changed from default to never"), 

428 ("set_moderation_auto_approve_deadline", "Changed from never to 172800 seconds"), 

429 ("set_moderation_auto_approve_deadline", "Changed from 172800 seconds to default"), 

430 ] 

431 

432 

433def test_ShadowUser_blank_note(db): 

434 super_user, super_token = generate_user(is_superuser=True) 

435 normal_user, _ = generate_user() 

436 

437 with real_admin_session(super_token) as api: 

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

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

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

441 

442 

443def test_AddAdminNote(db): 

444 super_user, super_token = generate_user(is_superuser=True) 

445 normal_user, _ = generate_user() 

446 admin_note1 = "User reported strange behavior" 

447 admin_note2 = "Insert private information here" 

448 

449 with real_admin_session(super_token) as api: 

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

451 assert res.user_id == normal_user.id 

452 assert res.username == normal_user.username 

453 assert res.email == normal_user.email 

454 assert res.gender == normal_user.gender 

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

456 assert not res.banned 

457 assert not res.deleted 

458 assert len(res.admin_actions) == 1 

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

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

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

462 

463 with real_admin_session(super_token) as api: 

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

465 assert len(res.admin_actions) == 2 

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

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

468 

469 

470def test_AddAdminNote_blank(db): 

471 super_user, super_token = generate_user(is_superuser=True) 

472 normal_user, _ = generate_user() 

473 empty_admin_note = " \t \n " 

474 

475 with real_admin_session(super_token) as api: 

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

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

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

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

480 

481 

482def test_AddAdminNote_data(db): 

483 super_user, super_token = generate_user(is_superuser=True) 

484 normal_user, _ = generate_user() 

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

486 

487 with real_admin_session(super_token) as api: 

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

489 assert len(res.admin_actions) == 1 

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

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

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

493 

494 

495def test_AddAdminNote_both_note_and_data(db): 

496 super_user, super_token = generate_user(is_superuser=True) 

497 normal_user, _ = generate_user() 

498 

499 with real_admin_session(super_token) as api: 

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

501 api.AddAdminNote( 

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

503 ) 

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

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

506 

507 

508def test_AddAdminNote_neither(db): 

509 super_user, super_token = generate_user(is_superuser=True) 

510 normal_user, _ = generate_user() 

511 

512 with real_admin_session(super_token) as api: 

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

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

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

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

517 

518 

519def test_AddAdminNote_invalid_json(db): 

520 super_user, super_token = generate_user(is_superuser=True) 

521 normal_user, _ = generate_user() 

522 

523 with real_admin_session(super_token) as api: 

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

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

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

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

528 

529 

530def test_admin_content_reports(db): 

531 super_user, super_token = generate_user(is_superuser=True) 

532 normal_user, token = generate_user() 

533 bad_user1, _ = generate_user() 

534 bad_user2, _ = generate_user() 

535 

536 with reporting_session(token) as api: 

537 api.Report( 

538 reporting_pb2.ReportReq( 

539 reason="spam", 

540 description="r1", 

541 content_ref="comment/123", 

542 author_user=bad_user1.username, 

543 user_agent="n/a", 

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

545 ) 

546 ) 

547 api.Report( 

548 reporting_pb2.ReportReq( 

549 reason="spam", 

550 description="r2", 

551 content_ref="comment/124", 

552 author_user=bad_user2.username, 

553 user_agent="n/a", 

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

555 ) 

556 ) 

557 api.Report( 

558 reporting_pb2.ReportReq( 

559 reason="something else", 

560 description="r3", 

561 content_ref="page/321", 

562 author_user=bad_user1.username, 

563 user_agent="n/a", 

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

565 ) 

566 ) 

567 

568 with session_scope() as session: 

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

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

571 ) 

572 

573 with real_admin_session(super_token) as api: 

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

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

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

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

578 

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

580 rep = res.content_report 

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

582 assert rep.reporting_user_id == normal_user.id 

583 assert rep.author_user_id == bad_user2.id 

584 assert rep.reason == "spam" 

585 assert rep.description == "r2" 

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

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

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

589 

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

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

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

593 

594 

595def test_DeleteUser(db): 

596 super_user, super_token = generate_user(is_superuser=True) 

597 normal_user, normal_token = generate_user() 

598 

599 with real_admin_session(super_token) as api: 

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

601 assert res.user_id == normal_user.id 

602 assert res.username == normal_user.username 

603 assert res.email == normal_user.email 

604 assert res.gender == normal_user.gender 

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

606 assert not res.banned 

607 assert res.deleted 

608 

609 with real_admin_session(super_token) as api: 

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

611 assert res.user_id == normal_user.id 

612 assert res.username == normal_user.username 

613 assert res.email == normal_user.email 

614 assert res.gender == normal_user.gender 

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

616 assert not res.banned 

617 assert not res.deleted 

618 

619 

620def test_RecoverDeletedUser_after_user_initiated_deletion(db, push_collector: PushCollector): 

621 """ 

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

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

624 these fields to satisfy the undelete_nullity database constraint. 

625 """ 

626 super_user, super_token = generate_user(is_superuser=True) 

627 normal_user, normal_token = generate_user() 

628 user_id = normal_user.id 

629 

630 # User initiates account deletion 

631 with account_session(normal_token) as account: 

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

633 

634 # Get the deletion confirmation token 

635 with session_scope() as session: 

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

637 

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

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

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

641 

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

643 with session_scope() as session: 

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

645 assert user.deleted_at is not None 

646 assert user.undelete_token is not None 

647 assert user.undelete_until is not None 

648 

649 # Admin recovers the user 

650 with real_admin_session(super_token) as api: 

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

652 assert res.user_id == user_id 

653 assert not res.deleted 

654 

655 # Verify undelete fields are cleared 

656 with session_scope() as session: 

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

658 assert user.deleted_at is None 

659 assert user.undelete_token is None 

660 assert user.undelete_until is None 

661 

662 

663def test_RecoverDeletedUser_email_reused(db): 

664 super_user, super_token = generate_user(is_superuser=True) 

665 normal_user, _ = generate_user() 

666 

667 with session_scope() as session: 

668 session.execute(update(User).where(User.id == normal_user.id).values(deleted_at=func.now())) 

669 

670 new_user, _ = generate_user(email=normal_user.email) 

671 

672 with real_admin_session(super_token) as api: 

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

674 api.GetUserDetails(admin_pb2.GetUserDetailsReq(user=normal_user.email)) 

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

676 assert ( 

677 e.value.details() 

678 == "That email address is used by more than one user, look them up by username or ID instead." 

679 ) 

680 

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

682 api.RecoverDeletedUser(admin_pb2.RecoverDeletedUserReq(user=normal_user.username)) 

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

684 

685 

686def test_SearchUsers_exact_email(db): 

687 super_user, super_token = generate_user(is_superuser=True) 

688 deleted_user, _ = generate_user(email="first_last@couchers.org.invalid", delete_user=True) 

689 live_user, _ = generate_user(email="first_last@couchers.org.invalid") 

690 wildcard_match, _ = generate_user(email="firstxlast@couchers.org.invalid") 

691 

692 with real_admin_session(super_token) as api: 

693 res = api.SearchUsers(admin_pb2.SearchUsersReq(email="first_last@couchers.org.invalid")) 

694 assert {u.user_id for u in res.users} == {deleted_user.id, live_user.id, wildcard_match.id} 

695 

696 res = api.SearchUsers(admin_pb2.SearchUsersReq(exact_email="First_Last@couchers.org.invalid")) 

697 assert {u.user_id for u in res.users} == {deleted_user.id, live_user.id} 

698 

699 

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

701 with session_scope() as session: 

702 super_user, super_token = generate_user(is_superuser=True) 

703 normal_user, normal_token = generate_user() 

704 

705 assert ( 

706 session.execute( 

707 select(func.count()) 

708 .select_from(UserSession) 

709 .where(UserSession.is_api_key == True) 

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

711 ).scalar_one() 

712 == 0 

713 ) 

714 

715 with real_admin_session(super_token) as api: 

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

717 

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

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

720 

721 with session_scope() as session: 

722 token = session.execute( 

723 select(UserSession.token) 

724 .where(UserSession.is_valid) 

725 .where(UserSession.is_api_key == True) 

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

727 ).scalar_one() 

728 

729 assert token in email.plain 

730 assert token in email.html 

731 

732 assert email.recipient == normal_user.email 

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

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

735 assert unique_string in email.plain 

736 assert unique_string in email.html 

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

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

739 

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

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

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

743 

744 

745def test_GetChats(db): 

746 super_user, super_token = generate_user(is_superuser=True) 

747 normal_user, normal_token = generate_user() 

748 

749 with real_admin_session(super_token) as api: 

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

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

752 assert res.user.username == normal_user.username 

753 assert res.user.name == normal_user.name 

754 # New user should have no chats 

755 assert len(res.host_requests) == 0 

756 assert len(res.group_chats) == 0 

757 

758 

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

760 super_user, super_token = generate_user(is_superuser=True) 

761 normal_user, normal_token = generate_user() 

762 

763 with real_admin_session(super_token) as api: 

764 # can add a badge 

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

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

767 assert "swagster" in res.badges 

768 

769 # badge emails are disabled by default 

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

771 

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

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

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

775 

776 # can't add/edit special tags 

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

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

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

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

781 

782 # double add badge 

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

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

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

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

787 

788 # can remove badge 

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

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

791 assert "swagster" not in res.badges 

792 

793 # badge emails are disabled by default 

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

795 

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

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

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

799 

800 # not found on user 

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

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

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

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

805 

806 # not found in general 

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

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

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

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

811 

812 

813def test_DeleteEvent(db): 

814 super_user, super_token = generate_user(is_superuser=True) 

815 normal_user, normal_token = generate_user() 

816 

817 with session_scope() as session: 

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

819 

820 res = events_api.create_event(token=normal_token) 

821 event_id = res.event_id 

822 assert not res.is_deleted 

823 

824 with session_scope() as session: 

825 with real_admin_session(super_token) as api: 

826 api.DeleteEvent( 

827 admin_pb2.DeleteEventReq( 

828 event_id=event_id, 

829 ) 

830 ) 

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

832 assert occurrence.is_deleted 

833 

834 

835def test_ListUserIds(db): 

836 super_user, super_token = generate_user(is_superuser=True) 

837 normal_user, normal_token = generate_user() 

838 

839 with real_admin_session(super_token) as api: 

840 res = api.ListUserIds( 

841 admin_pb2.ListUserIdsReq( 

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

843 end_time=Timestamp_from_datetime(now()), 

844 ) 

845 ) 

846 assert len(res.user_ids) == 2 

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

848 

849 with real_admin_session(super_token) as api: 

850 res = api.ListUserIds( 

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

852 ) 

853 assert res.user_ids == [] 

854 

855 

856def test_EditReferenceText(db): 

857 super_user, super_token = generate_user(is_superuser=True) 

858 test_new_text = "New Text" 

859 

860 user1, user1_token = generate_user() 

861 user2, user2_token = generate_user() 

862 make_friends(user1, user2) 

863 

864 with session_scope() as session: 

865 with references_session(user1_token) as api: 

866 reference = api.WriteFriendReference( 

867 references_pb2.WriteFriendReferenceReq( 

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

869 ) 

870 ) 

871 

872 with real_admin_session(super_token) as admin_api: 

873 admin_api.EditReferenceText( 

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

875 ) 

876 

877 session.expire_all() 

878 

879 modified_reference = session.execute( 

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

881 ).scalar_one() 

882 assert modified_reference.text == test_new_text 

883 

884 

885def test_DeleteReference_deprecated(db): 

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

887 super_user, super_token = generate_user(is_superuser=True) 

888 

889 user1, user1_token = generate_user() 

890 user2, user2_token = generate_user() 

891 make_friends(user1, user2) 

892 

893 with references_session(user1_token) as api: 

894 reference = api.WriteFriendReference( 

895 references_pb2.WriteFriendReferenceReq( 

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

897 ) 

898 ) 

899 

900 with real_admin_session(super_token) as admin_api: 

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

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

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

904 

905 

906def test_GetUserReferences(db): 

907 super_user, super_token = generate_user(is_superuser=True) 

908 

909 user1, user1_token = generate_user() 

910 user2, user2_token = generate_user() 

911 user3, user3_token = generate_user() 

912 make_friends(user1, user2) 

913 make_friends(user1, user3) 

914 make_friends(user2, user3) 

915 

916 # user1 writes reference about user2 

917 with references_session(user1_token) as api: 

918 ref1 = api.WriteFriendReference( 

919 references_pb2.WriteFriendReferenceReq( 

920 to_user_id=user2.id, 

921 text="Reference from user1 to user2", 

922 private_text="", 

923 was_appropriate=True, 

924 rating=1, 

925 ) 

926 ) 

927 

928 # user2 writes reference about user1 

929 with references_session(user2_token) as api: 

930 ref2 = api.WriteFriendReference( 

931 references_pb2.WriteFriendReferenceReq( 

932 to_user_id=user1.id, 

933 text="Reference from user2 to user1", 

934 private_text="Private note", 

935 was_appropriate=True, 

936 rating=0.8, 

937 ) 

938 ) 

939 

940 # user3 writes reference about user1 

941 with references_session(user3_token) as api: 

942 ref3 = api.WriteFriendReference( 

943 references_pb2.WriteFriendReferenceReq( 

944 to_user_id=user1.id, 

945 text="Reference from user3 to user1", 

946 private_text="", 

947 was_appropriate=False, 

948 rating=0.5, 

949 ) 

950 ) 

951 

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

953 with real_admin_session(super_token) as admin_api: 

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

955 

956 # user1 wrote 1 reference 

957 assert len(res.references_from) == 1 

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

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

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

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

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

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

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

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

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

967 # freshly written content starts out shadowed 

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

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

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

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

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

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

974 

975 # user1 received 2 references 

976 assert len(res.references_to) == 2 

977 # Ordered by id descending, so ref3 comes first 

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

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

980 

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

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

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

984 

985 

986def test_GetUserReferences_host_request(db): 

987 super_user, super_token = generate_user(is_superuser=True) 

988 

989 user1, _ = generate_user() 

990 user2, _ = generate_user() 

991 

992 with session_scope() as session: 

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

994 hosted_ref_id, _ = create_host_reference( 

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

996 ) 

997 host_request = session.execute( 

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

999 ).scalar_one() 

1000 from_date = date_to_api(host_request.from_date) 

1001 to_date = date_to_api(host_request.to_date) 

1002 

1003 with real_admin_session(super_token) as admin_api: 

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

1005 

1006 assert len(res.references_from) == 1 

1007 surfed_ref = res.references_from[0] 

1008 assert surfed_ref.reference_id == surfed_ref_id 

1009 assert surfed_ref.reference_type == "surfed" 

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

1011 assert surfed_ref.from_user.username == user1.username 

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

1013 assert surfed_ref.to_user.username == user2.username 

1014 assert surfed_ref.moderation_visibility == "visible" 

1015 assert surfed_ref.host_request_id == host_request_id 

1016 assert surfed_ref.hosting_city == "Test City" 

1017 assert surfed_ref.from_date == from_date 

1018 assert surfed_ref.to_date == to_date 

1019 assert surfed_ref.status == "confirmed" 

1020 

1021 assert len(res.references_to) == 1 

1022 hosted_ref = res.references_to[0] 

1023 assert hosted_ref.reference_id == hosted_ref_id 

1024 assert hosted_ref.reference_type == "hosted" 

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

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

1027 assert hosted_ref.host_request_id == host_request_id 

1028 assert hosted_ref.hosting_city == "Test City" 

1029 assert hosted_ref.from_date == from_date 

1030 assert hosted_ref.to_date == to_date 

1031 assert hosted_ref.status == "confirmed" 

1032 

1033 

1034def test_GetUserReferences_not_found(db): 

1035 super_user, super_token = generate_user(is_superuser=True) 

1036 

1037 with real_admin_session(super_token) as admin_api: 

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

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

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

1041 

1042 

1043def test_GetFriendRequests(db): 

1044 super_user, super_token = generate_user(is_superuser=True) 

1045 

1046 user1, _ = generate_user() 

1047 user2, _ = generate_user() 

1048 user3, _ = generate_user() 

1049 user4, _ = generate_user() 

1050 

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

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

1053 with session_scope() as session: 

1054 mod_state = ModerationState( 

1055 object_type=ModerationObjectType.friend_request, 

1056 object_id=0, 

1057 visibility=visibility, 

1058 ) 

1059 session.add(mod_state) 

1060 session.flush() 

1061 rel = FriendRelationship( 

1062 from_user_id=from_user_id, 

1063 to_user_id=to_user_id, 

1064 status=status, 

1065 moderation_state_id=mod_state.id, 

1066 time_responded=time_responded, 

1067 ) 

1068 session.add(rel) 

1069 session.flush() 

1070 mod_state.object_id = rel.id 

1071 

1072 # user1 -> user2: pending, shadowed 

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

1074 # user1 -> user3: accepted, visible 

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

1076 # user4 -> user1: rejected, visible 

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

1078 

1079 with real_admin_session(super_token) as admin_api: 

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

1081 

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

1083 assert len(res.sent) == 2 

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

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

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

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

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

1089 

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

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

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

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

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

1095 

1096 # user1 received one: from user4 (rejected) 

1097 assert len(res.received) == 1 

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

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

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

1101 

1102 

1103def test_GetFriendRequests_not_found(db): 

1104 super_user, super_token = generate_user(is_superuser=True) 

1105 

1106 with real_admin_session(super_token) as admin_api: 

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

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

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

1110 

1111 

1112def test_GetUserBlocks(db): 

1113 super_user, super_token = generate_user(is_superuser=True) 

1114 

1115 user1, token1 = generate_user() 

1116 user2, _ = generate_user() 

1117 user3, token3 = generate_user() 

1118 user4, token4 = generate_user() 

1119 

1120 with blocking_session(token1) as user_blocks: 

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

1122 

1123 with blocking_session(token3) as user_blocks: 

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

1125 

1126 with blocking_session(token4) as user_blocks: 

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

1128 

1129 with real_admin_session(super_token) as admin_api: 

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

1131 

1132 assert len(res.blocked_users) == 1 

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

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

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

1136 

1137 # most recently blocked first 

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

1139 

1140 with real_admin_session(super_token) as admin_api: 

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

1142 

1143 assert len(res.blocked_users) == 0 

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

1145 

1146 

1147def test_GetUserBlocks_not_found(db): 

1148 super_user, super_token = generate_user(is_superuser=True) 

1149 

1150 with real_admin_session(super_token) as admin_api: 

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

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

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

1154 

1155 

1156def test_AddUsersToModerationUserList(db): 

1157 super_user, super_token = generate_user(is_superuser=True) 

1158 user1, _ = generate_user() 

1159 user2, _ = generate_user() 

1160 user3, _ = generate_user() 

1161 user4, _ = generate_user() 

1162 user5, _ = generate_user() 

1163 moderation_list_id = add_users_to_new_moderation_list([user1]) 

1164 

1165 with session_scope() as session: 

1166 with real_admin_session(super_token) as api: 

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

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

1169 api.AddUsersToModerationUserList( 

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

1171 ) 

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

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

1174 

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

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

1177 api.AddUsersToModerationUserList( 

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

1179 ) 

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

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

1182 

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

1184 res = api.AddUsersToModerationUserList( 

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

1186 ) 

1187 assert res.moderation_list_id > 0 

1188 with session_scope() as session: 

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

1190 assert moderation_user_list is not None 

1191 assert len(moderation_user_list.users) == 3 

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

1193 

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

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

1196 assert len(listRes.moderation_lists) == 1 

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

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

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

1200 

1201 # Test user can be in multiple moderation lists 

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

1203 assert len(listRes3.moderation_lists) == 2 

1204 

1205 # Test adding users to an existing moderation list 

1206 res2 = api.AddUsersToModerationUserList( 

1207 admin_pb2.AddUsersToModerationUserListReq( 

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

1209 ), 

1210 ) 

1211 assert res2.moderation_list_id == moderation_list_id 

1212 with session_scope() as session: 

1213 moderation_user_list = session.get_one(ModerationUserList, moderation_list_id) 

1214 assert len(moderation_user_list.users) == 3 

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

1216 

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

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

1219 assert len(listRes2.moderation_lists) == 1 

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

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

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

1223 

1224 

1225def test_RemoveUserFromModerationUserList(db): 

1226 super_user, super_token = generate_user(is_superuser=True) 

1227 user1, _ = generate_user() 

1228 user2, _ = generate_user() 

1229 user3, _ = generate_user() 

1230 moderation_list_id = add_users_to_new_moderation_list([user1, user2]) 

1231 

1232 with real_admin_session(super_token) as api: 

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

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

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

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

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

1238 

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

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

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

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

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

1244 

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

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

1247 api.RemoveUserFromModerationUserList( 

1248 admin_pb2.RemoveUserFromModerationUserListReq( 

1249 user=user3.username, moderation_list_id=moderation_list_id 

1250 ) 

1251 ) 

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

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

1254 

1255 # Test successful removal 

1256 api.RemoveUserFromModerationUserList( 

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

1258 ) 

1259 with session_scope() as session: 

1260 moderation_user_list = session.get_one(ModerationUserList, moderation_list_id) 

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

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

1263 

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

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

1266 assert len(listRes.moderation_lists) == 0 

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

1268 assert len(listRes2.moderation_lists) == 1 

1269 

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

1271 api.RemoveUserFromModerationUserList( 

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

1273 ) 

1274 with session_scope() as session: 

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

1276 

1277 

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

1279 super_user, super_token = generate_user(is_superuser=True) 

1280 

1281 user, token = generate_user() 

1282 user_id = user.id 

1283 

1284 with real_admin_session(super_token) as admin_api: 

1285 url = admin_api.CreateAccountDeletionLink( 

1286 admin_pb2.CreateAccountDeletionLinkReq(user=user.username) 

1287 ).account_deletion_confirm_url 

1288 

1289 assert push_collector.count_for_user(user_id) == 0 

1290 

1291 with session_scope() as session: 

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

1293 token = token_o.token 

1294 assert token_o.user.id == user_id 

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

1296 

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

1298 auth_api.ConfirmDeleteAccount( 

1299 auth_pb2.ConfirmDeleteAccountReq( 

1300 token=token, 

1301 ) 

1302 ) 

1303 

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

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

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

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

1308 

1309 

1310def test_AccessStats(db): 

1311 super_user, super_token = generate_user(is_superuser=True) 

1312 normal_user, normal_token = generate_user() 

1313 

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

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

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

1317 # crash the proto string assignment. 

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

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

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

1321 with session_scope() as session: 

1322 session.add( 

1323 UserActivity( 

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

1325 ) 

1326 ) 

1327 session.add( 

1328 UserActivity( 

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

1330 ) 

1331 ) 

1332 session.add( 

1333 UserActivity( 

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

1335 ) 

1336 ) 

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

1338 

1339 with real_admin_session(super_token) as api: 

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

1341 

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

1343 assert "1.2.3.4" in by_ip 

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

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

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

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

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

1349 assert "" in by_ip 

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

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

1352 assert "9.9.9.9" not in by_ip 

1353 

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

1355 with real_admin_session(super_token) as api: 

1356 res = api.AccessStats( 

1357 admin_pb2.AccessStatsReq( 

1358 user=normal_user.username, 

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

1360 end_time=Timestamp_from_datetime(now()), 

1361 ) 

1362 ) 

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

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

1365 

1366 

1367def test_SetLastDonated(db): 

1368 super_user, super_token = generate_user(is_superuser=True) 

1369 normal_user, normal_token = generate_user(last_donated=None) 

1370 

1371 with real_admin_session(super_token) as api: 

1372 # user starts with no last_donated 

1373 with session_scope() as session: 

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

1375 assert user.last_donated is None 

1376 

1377 # can set last_donated 

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

1379 res = api.SetLastDonated( 

1380 admin_pb2.SetLastDonatedReq( 

1381 user=normal_user.username, 

1382 last_donated=Timestamp_from_datetime(donation_time), 

1383 ) 

1384 ) 

1385 

1386 with session_scope() as session: 

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

1388 assert user.last_donated is not None 

1389 # check timestamp is close (within a second) 

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

1391 

1392 # can clear last_donated by not setting the field 

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

1394 

1395 with session_scope() as session: 

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

1397 assert user.last_donated is None 

1398 

1399 # user not found 

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

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

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

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

1404 

1405 

1406def test_admin_actions_level(db): 

1407 super_user, super_token = generate_user(is_superuser=True) 

1408 normal_user, _ = generate_user() 

1409 

1410 with real_admin_session(super_token) as api: 

1411 # Default level is NORMAL 

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

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

1414 

1415 # Explicitly set to DEBUG 

1416 res = api.AddAdminNote( 

1417 admin_pb2.AddAdminNoteReq( 

1418 user=normal_user.username, 

1419 admin_note="debug note", 

1420 level=admin_pb2.ADMIN_ACTION_LEVEL_DEBUG, 

1421 ) 

1422 ) 

1423 assert len(res.admin_actions) == 2 

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

1425 

1426 # Explicitly set to HIGH 

1427 res = api.AddAdminNote( 

1428 admin_pb2.AddAdminNoteReq( 

1429 user=normal_user.username, 

1430 admin_note="high note", 

1431 level=admin_pb2.ADMIN_ACTION_LEVEL_HIGH, 

1432 ) 

1433 ) 

1434 assert len(res.admin_actions) == 3 

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

1436 

1437 # Explicitly set to TRACE 

1438 res = api.AddAdminNote( 

1439 admin_pb2.AddAdminNoteReq( 

1440 user=normal_user.username, 

1441 admin_note="trace note", 

1442 level=admin_pb2.ADMIN_ACTION_LEVEL_TRACE, 

1443 ) 

1444 ) 

1445 assert len(res.admin_actions) == 4 

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

1447 

1448 

1449def test_admin_actions_on_mutations(db, push_collector: PushCollector): 

1450 super_user, super_token = generate_user(is_superuser=True) 

1451 normal_user, _ = generate_user() 

1452 

1453 original_gender = normal_user.gender 

1454 original_birthdate = normal_user.birthdate 

1455 

1456 with real_admin_session(super_token) as api: 

1457 # ChangeUserGender 

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

1459 assert any( 

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

1461 for a in res.admin_actions 

1462 ) 

1463 

1464 # ChangeUserBirthdate 

1465 res = api.ChangeUserBirthdate( 

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

1467 ) 

1468 assert any( 

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

1470 for a in res.admin_actions 

1471 ) 

1472 

1473 # SetPassportSexGenderException 

1474 res = api.SetPassportSexGenderException( 

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

1476 ) 

1477 assert any( 

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

1479 for a in res.admin_actions 

1480 ) 

1481 

1482 # SendModNote with notify 

1483 res = api.SendModNote( 

1484 admin_pb2.SendModNoteReq( 

1485 user=normal_user.username, 

1486 content="Please update your profile", 

1487 internal_id="test1", 

1488 notification=admin_pb2.MOD_NOTE_NOTIFICATION_NOTIFY, 

1489 ) 

1490 ) 

1491 assert any( 

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

1493 for a in res.admin_actions 

1494 ) 

1495 

1496 # SendModNote without notifying 

1497 res = api.SendModNote( 

1498 admin_pb2.SendModNoteReq( 

1499 user=normal_user.username, 

1500 content="Silent note", 

1501 internal_id="test2", 

1502 notification=admin_pb2.MOD_NOTE_NOTIFICATION_NONE, 

1503 ) 

1504 ) 

1505 assert any( 

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

1507 ) 

1508 

1509 # SendModNote including the note text in the email 

1510 res = api.SendModNote( 

1511 admin_pb2.SendModNoteReq( 

1512 user=normal_user.username, 

1513 content="Emailed note", 

1514 internal_id="test3", 

1515 notification=admin_pb2.MOD_NOTE_NOTIFICATION_NOTIFY_WITH_CONTENT, 

1516 ) 

1517 ) 

1518 assert any( 

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

1520 for a in res.admin_actions 

1521 ) 

1522 

1523 # DeleteUser 

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

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

1526 assert any( 

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

1528 ) 

1529 

1530 # RecoverDeletedUser 

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

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

1533 

1534 # MarkUserNeedsLocationUpdate 

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

1536 assert any( 

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

1538 for a in res.admin_actions 

1539 ) 

1540 

1541 # SetLastDonated 

1542 res = api.SetLastDonated( 

1543 admin_pb2.SetLastDonatedReq( 

1544 user=normal_user.username, 

1545 last_donated=Timestamp_from_datetime(now()), 

1546 ) 

1547 ) 

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

1549 

1550 

1551def test_create_admin_tag(db): 

1552 super_user, super_token = generate_user(is_superuser=True) 

1553 

1554 with real_admin_session(super_token) as api: 

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

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

1557 assert res.admin_tag_id > 0 

1558 

1559 

1560def test_create_admin_tag_duplicate(db): 

1561 super_user, super_token = generate_user(is_superuser=True) 

1562 

1563 with real_admin_session(super_token) as api: 

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

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

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

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

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

1569 

1570 

1571def test_create_admin_tag_empty(db): 

1572 super_user, super_token = generate_user(is_superuser=True) 

1573 

1574 with real_admin_session(super_token) as api: 

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

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

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

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

1579 

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

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

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

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

1584 

1585 

1586def test_list_admin_tags(db): 

1587 super_user, super_token = generate_user(is_superuser=True) 

1588 

1589 with real_admin_session(super_token) as api: 

1590 # Empty initially 

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

1592 assert len(res.tags) == 0 

1593 

1594 # Add some tags 

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

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

1597 

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

1599 assert len(res.tags) == 2 

1600 # Ordered alphabetically 

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

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

1603 

1604 

1605def test_add_admin_tag_to_user(db): 

1606 super_user, super_token = generate_user(is_superuser=True) 

1607 normal_user, _ = generate_user() 

1608 

1609 with real_admin_session(super_token) as api: 

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

1611 

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

1613 assert "vip" in res.admin_tags 

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

1615 

1616 

1617def test_add_admin_tag_to_user_duplicate(db): 

1618 super_user, super_token = generate_user(is_superuser=True) 

1619 normal_user, _ = generate_user() 

1620 

1621 with real_admin_session(super_token) as api: 

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

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

1624 

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

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

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

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

1629 

1630 

1631def test_add_admin_tag_to_user_tag_not_found(db): 

1632 super_user, super_token = generate_user(is_superuser=True) 

1633 normal_user, _ = generate_user() 

1634 

1635 with real_admin_session(super_token) as api: 

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

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

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

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

1640 

1641 

1642def test_remove_admin_tag_from_user(db): 

1643 super_user, super_token = generate_user(is_superuser=True) 

1644 normal_user, _ = generate_user() 

1645 

1646 with real_admin_session(super_token) as api: 

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

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

1649 

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

1651 assert "vip" not in res.admin_tags 

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

1653 

1654 

1655def test_remove_admin_tag_from_user_not_assigned(db): 

1656 super_user, super_token = generate_user(is_superuser=True) 

1657 normal_user, _ = generate_user() 

1658 

1659 with real_admin_session(super_token) as api: 

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

1661 

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

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

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

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

1666 

1667 

1668def test_search_users_by_admin_tag(db): 

1669 super_user, super_token = generate_user(is_superuser=True) 

1670 user1, _ = generate_user() 

1671 user2, _ = generate_user() 

1672 user3, _ = generate_user() 

1673 

1674 with real_admin_session(super_token) as api: 

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

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

1677 

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

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

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

1681 

1682 # Search for users with "vip" tag 

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

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

1685 assert user1.id in user_ids 

1686 assert user2.id in user_ids 

1687 assert user3.id not in user_ids 

1688 

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

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

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

1692 assert user2.id in user_ids 

1693 assert user1.id not in user_ids 

1694 

1695 # Search for non-existent tag returns no results 

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

1697 assert len(res.users) == 0 

1698 

1699 

1700def test_search_users_by_admin_note(db): 

1701 super_user, super_token = generate_user(is_superuser=True) 

1702 user1, _ = generate_user() 

1703 user2, _ = generate_user() 

1704 

1705 with real_admin_session(super_token) as api: 

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

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

1708 

1709 # Search by admin action log content (ilike) 

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

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

1712 assert user1.id in user_ids 

1713 assert user2.id not in user_ids 

1714 

1715 

1716def test_ListAdminActions_empty(db): 

1717 super_user, super_token = generate_user(is_superuser=True) 

1718 

1719 with real_admin_session(super_token) as api: 

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

1721 assert len(res.admin_actions) == 0 

1722 assert res.next_page_token == "" 

1723 

1724 

1725def test_ListAdminActions_returns_newest_first_with_target_info(db): 

1726 super_user, super_token = generate_user(is_superuser=True) 

1727 user1, _ = generate_user() 

1728 user2, _ = generate_user() 

1729 

1730 with real_admin_session(super_token) as api: 

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

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

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

1734 

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

1736 

1737 assert len(res.admin_actions) == 3 

1738 # Newest first 

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

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

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

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

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

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

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

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

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

1748 

1749 

1750def test_ListAdminActions_filter_by_admin_and_target(db): 

1751 super1, super1_token = generate_user(is_superuser=True) 

1752 super2, super2_token = generate_user(is_superuser=True) 

1753 user1, _ = generate_user() 

1754 user2, _ = generate_user() 

1755 

1756 with real_admin_session(super1_token) as api: 

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

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

1759 with real_admin_session(super2_token) as api: 

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

1761 

1762 with real_admin_session(super1_token) as api: 

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

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

1765 

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

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

1768 

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

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

1771 

1772 

1773def test_ListAdminActions_pagination(db): 

1774 super_user, super_token = generate_user(is_superuser=True) 

1775 user, _ = generate_user() 

1776 

1777 with real_admin_session(super_token) as api: 

1778 for i in range(3): 

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

1780 

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

1782 assert len(res.admin_actions) == 2 

1783 assert res.next_page_token != "" 

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

1785 

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

1787 assert len(res2.admin_actions) == 1 

1788 assert res2.next_page_token == "" 

1789 

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

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

1792 

1793 

1794def test_ListUserUploads(db): 

1795 super_user, super_token = generate_user(is_superuser=True) 

1796 user, _ = generate_user(complete_profile=False) 

1797 other_user, _ = generate_user() 

1798 

1799 with session_scope() as session: 

1800 for i in range(3): 

1801 session.add( 

1802 Upload( 

1803 key=f"key{i}", 

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

1805 creator_user_id=user.id, 

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

1807 ) 

1808 ) 

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

1810 

1811 with real_admin_session(super_token) as api: 

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

1813 

1814 assert len(res.uploads) == 3 

1815 assert res.next_page_token == "" 

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

1817 

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

1819 assert upload0.credit == "credit 0" 

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

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

1822 assert upload0.HasField("created") 

1823 

1824 

1825def test_ListUserUploads_metadata(db): 

1826 super_user, super_token = generate_user(is_superuser=True) 

1827 user, _ = generate_user(complete_profile=False) 

1828 

1829 with session_scope() as session: 

1830 session.add( 

1831 Upload( 

1832 key="with_metadata", 

1833 filename="photo.jpg", 

1834 creator_user_id=user.id, 

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

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

1837 original_filename="IMG_1234.HEIC", 

1838 original_format="heif", 

1839 original_size=123456, 

1840 original_width=4032, 

1841 original_height=3024, 

1842 ) 

1843 ) 

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

1845 

1846 with real_admin_session(super_token) as api: 

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

1848 

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

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

1851 

1852 metadata = uploads["with_metadata"].metadata 

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

1854 assert metadata.parse_error == "" 

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

1856 assert metadata.original_format == "heif" 

1857 assert metadata.original_size == 123456 

1858 assert metadata.original_width == 4032 

1859 assert metadata.original_height == 3024 

1860 

1861 

1862def test_ListUserUploads_pagination(db): 

1863 super_user, super_token = generate_user(is_superuser=True) 

1864 user, _ = generate_user(complete_profile=False) 

1865 

1866 with session_scope() as session: 

1867 for i in range(3): 

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

1869 

1870 with real_admin_session(super_token) as api: 

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

1872 assert len(res.uploads) == 2 

1873 assert res.next_page_token != "" 

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

1875 

1876 res2 = api.ListUserUploads( 

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

1878 ) 

1879 assert len(res2.uploads) == 1 

1880 assert res2.next_page_token == "" 

1881 

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

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

1884 

1885 

1886def test_ListUserUploads_uses(db): 

1887 super_user, super_token = generate_user(is_superuser=True) 

1888 user, _ = generate_user(complete_profile=False) 

1889 

1890 with session_scope() as session: 

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

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

1893 gallery = PhotoGallery(owner_user_id=user.id) 

1894 session.add(gallery) 

1895 session.flush() 

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

1897 

1898 with real_admin_session(super_token) as api: 

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

1900 

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

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

1903 

1904 used_uses = uploads["used_key"].uses 

1905 assert len(used_uses) == 1 

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

1907 assert used_uses[0].is_current 

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

1909 

1910 

1911def test_ListUserUploads_not_found(db): 

1912 super_user, super_token = generate_user(is_superuser=True) 

1913 

1914 with real_admin_session(super_token) as api: 

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

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

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

1918 

1919 

1920# community invite feature tested in test_events.py 

1921# SendBlogPostNotification tested in test_notifications.py 

1922# MarkUserNeedsLocationUpdate tested in test_jail.py 

1923 

1924 

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

1926 return { 

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

1928 "createdAt": created_at, 

1929 "runtimeVersion": fingerprint, 

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

1931 "assets": [], 

1932 "metadata": {}, 

1933 "extra": {}, 

1934 } 

1935 

1936 

1937def _ota_signed_multipart(manifest): 

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

1939 boundary = "COUCHERS_OTA_BOUNDARY" 

1940 

1941 def part(name, body, content_type): 

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

1943 

1944 body = ( 

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

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

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

1948 ) 

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

1950 

1951 

1952def _patch_ota_cdn(manifests): 

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

1954 def fake(url): 

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

1956 if version not in manifests: 

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

1958 return _ota_signed_multipart(manifests[version]) 

1959 

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

1961 

1962 

1963def test_CreateOTAPackage(db): 

1964 super_user, super_token = generate_user(is_superuser=True) 

1965 

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

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

1968 res = api.CreateOTAPackage( 

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

1970 ) 

1971 

1972 assert res.platform == admin_pb2.OTA_PLATFORM_IOS 

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

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

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

1976 assert res.banned is False 

1977 assert res.live is True 

1978 assert res.creator_user_id == super_user.id 

1979 

1980 

1981def test_CreateOTAPackage_invalid(db): 

1982 _, super_token = generate_user(is_superuser=True) 

1983 

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

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

1986 # missing version 

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

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

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

1990 

1991 # nothing published at this version 

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

1993 api.CreateOTAPackage( 

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

1995 ) 

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

1997 

1998 # manifest present but missing required fields 

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

2000 api.CreateOTAPackage( 

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

2002 ) 

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

2004 

2005 

2006def test_CreateOTAPackage_rejects_duplicate_version(db): 

2007 _, super_token = generate_user(is_superuser=True) 

2008 

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

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

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

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

2013 api.CreateOTAPackage( 

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

2015 ) 

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

2017 

2018 

2019def test_ListOTAPackages(db): 

2020 _, super_token = generate_user(is_superuser=True) 

2021 

2022 manifests = { 

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

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

2025 "v1.3.2.android": _ota_manifest( 

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

2027 ), 

2028 } 

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

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

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

2032 api.CreateOTAPackage( 

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

2034 ) 

2035 

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

2037 # newest (by manifest createdAt) first 

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

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

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

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

2042 

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

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

2045 

2046 

2047def test_BanOTAPackage(db): 

2048 super_user, super_token = generate_user(is_superuser=True) 

2049 

2050 manifests = { 

2051 "v1.3.1.good": _ota_manifest( 

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

2053 ), 

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

2055 } 

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

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

2058 second = api.CreateOTAPackage( 

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

2060 ) 

2061 assert second.live is True 

2062 

2063 banned = api.BanOTAPackage( 

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

2065 ) 

2066 assert banned.banned is True 

2067 assert banned.banned_reason == "bad bundle" 

2068 assert banned.banned_by_user_id == super_user.id 

2069 assert banned.live is False 

2070 

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

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

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

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

2075 

2076 # banned packages are excluded by default 

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

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

2079 

2080 

2081def test_BanOTAPackage_requires_reason(db): 

2082 _, super_token = generate_user(is_superuser=True) 

2083 

2084 manifests = { 

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

2086 } 

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

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

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

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

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

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

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

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

2095 

2096 

2097def test_BanOTAPackage_not_found(db): 

2098 _, super_token = generate_user(is_superuser=True) 

2099 

2100 with real_admin_session(super_token) as api: 

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

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

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