Coverage for app/backend/src/tests/fixtures/sessions.py: 99%

302 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 18:39 +0000

1from collections.abc import Generator 

2from concurrent import futures 

3from contextlib import contextmanager 

4from typing import Any, NoReturn 

5from zoneinfo import ZoneInfo 

6 

7import grpc 

8from grpc._server import _validate_generic_rpc_handlers 

9 

10from couchers.context import make_interactive_context 

11from couchers.db import session_scope 

12from couchers.descriptor_pool import get_descriptor_pool 

13from couchers.i18n import LocalizationContext 

14from couchers.i18n.locales import DEFAULT_LOCALE 

15from couchers.interceptors import ( 

16 CouchersMiddlewareInterceptor, 

17 _try_get_and_update_user_details, 

18 check_permissions, 

19 find_auth_level, 

20) 

21from couchers.proto import ( 

22 account_pb2_grpc, 

23 admin_pb2_grpc, 

24 api_pb2_grpc, 

25 auth_pb2_grpc, 

26 blocking_pb2_grpc, 

27 bugs_pb2_grpc, 

28 communities_pb2_grpc, 

29 conversations_pb2_grpc, 

30 dashboard_pb2_grpc, 

31 discussions_pb2_grpc, 

32 donations_pb2_grpc, 

33 editor_pb2_grpc, 

34 events_pb2_grpc, 

35 galleries_pb2_grpc, 

36 gis_pb2_grpc, 

37 groups_pb2_grpc, 

38 iris_pb2_grpc, 

39 jail_pb2_grpc, 

40 media_pb2_grpc, 

41 moderation_pb2_grpc, 

42 notifications_pb2_grpc, 

43 pages_pb2_grpc, 

44 postal_verification_pb2_grpc, 

45 public_pb2_grpc, 

46 public_trips_pb2_grpc, 

47 references_pb2_grpc, 

48 reporting_pb2_grpc, 

49 requests_pb2_grpc, 

50 resources_pb2_grpc, 

51 search_pb2_grpc, 

52 stripe_pb2_grpc, 

53 threads_pb2_grpc, 

54) 

55from couchers.servicers.account import Account, Iris 

56from couchers.servicers.admin import Admin 

57from couchers.servicers.api import API 

58from couchers.servicers.auth import Auth 

59from couchers.servicers.blocking import Blocking 

60from couchers.servicers.bugs import Bugs 

61from couchers.servicers.communities import Communities 

62from couchers.servicers.conversations import Conversations 

63from couchers.servicers.dashboard import Dashboard 

64from couchers.servicers.discussions import Discussions 

65from couchers.servicers.donations import Donations, Stripe 

66from couchers.servicers.editor import Editor 

67from couchers.servicers.events import Events 

68from couchers.servicers.galleries import Galleries 

69from couchers.servicers.gis import GIS 

70from couchers.servicers.groups import Groups 

71from couchers.servicers.jail import Jail 

72from couchers.servicers.media import Media, get_media_auth_interceptor 

73from couchers.servicers.moderation import Moderation 

74from couchers.servicers.notifications import Notifications 

75from couchers.servicers.pages import Pages 

76from couchers.servicers.postal_verification import PostalVerification 

77from couchers.servicers.public import Public 

78from couchers.servicers.public_trips import PublicTrips 

79from couchers.servicers.references import References 

80from couchers.servicers.reporting import Reporting 

81from couchers.servicers.requests import Requests 

82from couchers.servicers.resources import Resources 

83from couchers.servicers.search import Search 

84from couchers.servicers.threads import Threads 

85 

86 

87class _MockCouchersContext: 

88 @property 

89 def headers(self): 

90 return {} 

91 

92 def get_header(self, name): 

93 return None 

94 

95 

96class CookieMetadataPlugin(grpc.AuthMetadataPlugin): 

97 """ 

98 Injects the right `cookie: couchers-sesh=...` header into the metadata 

99 """ 

100 

101 def __init__(self, token: str): 

102 self.token = token 

103 

104 def __call__(self, context, callback) -> None: 

105 callback((("cookie", f"couchers-sesh={self.token}"),), None) 

106 

107 

108class MetadataKeeperInterceptor(grpc.UnaryUnaryClientInterceptor): 

109 def __init__(self): 

110 self.latest_headers = {} 

111 

112 def intercept_unary_unary(self, continuation, client_call_details, request): 

113 call = continuation(client_call_details, request) 

114 self.latest_headers = dict(call.initial_metadata()) 

115 self.latest_header_raw = call.initial_metadata() 

116 return call 

117 

118 

119class FakeRpcError(grpc.RpcError): 

120 def __init__(self, code: grpc.StatusCode, details: str): 

121 self._code = code 

122 self._details = details 

123 

124 def code(self) -> grpc.StatusCode: 

125 return self._code 

126 

127 def details(self) -> str: 

128 return self._details 

129 

130 

131class MockGrpcContext: 

132 """ 

133 Pure mock of grpc.ServicerContext for testing. 

134 """ 

135 

136 def __init__(self): 

137 self._initial_metadata = [] 

138 self._invocation_metadata: list[tuple[str, str]] = [] 

139 

140 def abort(self, code: grpc.StatusCode, details: str) -> NoReturn: 

141 raise FakeRpcError(code, details) 

142 

143 def invocation_metadata(self) -> list[tuple[str, str]]: 

144 return self._invocation_metadata 

145 

146 def send_initial_metadata(self, metadata): 

147 self._initial_metadata.extend(metadata) 

148 

149 

150class FakeChannel: 

151 """ 

152 Mock gRPC channel for testing that orchestrates context creation. 

153 

154 This holds the test state (token) and creates proper CouchersContext 

155 instances when handlers are invoked. 

156 """ 

157 

158 def __init__(self, token: str | None = None, *, locale: str | None = None): 

159 self.handlers: dict[str, Any] = {} 

160 self._token = token 

161 self._locale = locale or DEFAULT_LOCALE 

162 self._pool = get_descriptor_pool() 

163 

164 def add_generic_rpc_handlers(self, generic_rpc_handlers: Any): 

165 _validate_generic_rpc_handlers(generic_rpc_handlers) 

166 self.handlers.update(generic_rpc_handlers[0]._method_handlers) 

167 

168 def unary_unary(self, method, request_serializer, response_deserializer): 

169 handler = self.handlers[method] 

170 

171 def fake_handler(request): 

172 auth_info = _try_get_and_update_user_details( 

173 self._token, 

174 is_api_key=False, 

175 ip_address="127.0.0.1", 

176 user_agent="Testing User-Agent", 

177 sofa=None, 

178 client_platform=None, 

179 ) 

180 auth_level = find_auth_level(self._pool, method) 

181 check_permissions(auth_info, auth_level) 

182 

183 # Do a full serialization cycle on the request and the 

184 # response to catch accidental use of unserializable data. 

185 request = handler.request_deserializer(request_serializer(request)) 

186 

187 with session_scope() as session: 

188 context = make_interactive_context( 

189 grpc_context=MockGrpcContext(), 

190 user_id=auth_info.user_id if auth_info else None, 

191 is_api_key=False, 

192 token=self._token if auth_info else None, 

193 localization=LocalizationContext( 

194 locale=(auth_info and auth_info.ui_language_preference) or self._locale, 

195 timezone=ZoneInfo((auth_info and auth_info.timezone) or "Etc/UTC"), 

196 ), 

197 sofa="test_sofa_cookie_value", 

198 ) 

199 

200 response = handler.unary_unary(request, context, session) 

201 

202 return response_deserializer(handler.response_serializer(response)) 

203 

204 return fake_handler 

205 

206 

207@contextmanager 

208def run_server(grpc_channel_options=(), token: str | None = None): 

209 with futures.ThreadPoolExecutor(1) as executor: 

210 if token: 

211 call_creds = grpc.metadata_call_credentials(CookieMetadataPlugin(token)) 

212 creds = grpc.composite_channel_credentials(grpc.local_channel_credentials(), call_creds) 

213 else: 

214 creds = grpc.local_channel_credentials() 

215 

216 srv = grpc.server(executor, interceptors=[CouchersMiddlewareInterceptor()]) 

217 port = srv.add_secure_port("localhost:0", grpc.local_server_credentials()) 

218 srv.start() 

219 

220 try: 

221 with grpc.secure_channel(f"localhost:{port}", creds, options=grpc_channel_options) as channel: 

222 metadata_interceptor = MetadataKeeperInterceptor() 

223 channel = grpc.intercept_channel(channel, metadata_interceptor) 

224 yield srv, channel, metadata_interceptor 

225 finally: 

226 srv.stop(None).wait() 

227 

228 

229# Sessions that start a real GRPC server. 

230@contextmanager 

231def auth_api_session( 

232 grpc_channel_options=(), 

233) -> Generator[tuple[auth_pb2_grpc.AuthStub, MetadataKeeperInterceptor]]: 

234 """ 

235 Create an Auth API for testing 

236 

237 This needs to use the real server since it plays around with headers 

238 """ 

239 with run_server(grpc_channel_options) as (server, channel, metadata_interceptor): 

240 auth_pb2_grpc.add_AuthServicer_to_server(Auth(), server) 

241 yield auth_pb2_grpc.AuthStub(channel), metadata_interceptor 

242 

243 

244@contextmanager 

245def real_api_session(token: str): 

246 """ 

247 Create an API for testing, using TCP sockets, uses the token for auth 

248 """ 

249 with run_server(token=token) as (server, channel, metadata_interceptor): 

250 api_pb2_grpc.add_APIServicer_to_server(API(), server) 

251 yield api_pb2_grpc.APIStub(channel) 

252 

253 

254@contextmanager 

255def real_admin_session(token: str): 

256 """ 

257 Create an Admin service for testing, using TCP sockets, uses the token for auth 

258 """ 

259 with run_server(token=token) as (server, channel, metadata_interceptor): 

260 admin_pb2_grpc.add_AdminServicer_to_server(Admin(), server) 

261 yield admin_pb2_grpc.AdminStub(channel) 

262 

263 

264@contextmanager 

265def real_editor_session(token: str): 

266 """ 

267 Create an Editor service for testing, using TCP sockets, uses the token for auth 

268 """ 

269 with run_server(token=token) as (server, channel, metadata_interceptor): 

270 editor_pb2_grpc.add_EditorServicer_to_server(Editor(), server) 

271 yield editor_pb2_grpc.EditorStub(channel) 

272 

273 

274@contextmanager 

275def real_moderation_session(token: str): 

276 """ 

277 Create a Moderation service for testing, using TCP sockets, uses the token for auth 

278 """ 

279 with run_server(token=token) as (server, channel, metadata_interceptor): 

280 moderation_pb2_grpc.add_ModerationServicer_to_server(Moderation(), server) 

281 yield moderation_pb2_grpc.ModerationStub(channel) 

282 

283 

284@contextmanager 

285def real_account_session(token: str): 

286 """ 

287 Create an Account service for testing, using TCP sockets, uses the token for auth 

288 """ 

289 with run_server(token=token) as (server, channel, metadata_interceptor): 

290 account_pb2_grpc.add_AccountServicer_to_server(Account(), server) 

291 yield account_pb2_grpc.AccountStub(channel) 

292 

293 

294@contextmanager 

295def real_jail_session(token: str): 

296 """ 

297 Create a Jail service for testing, using TCP sockets, uses the token for auth 

298 """ 

299 with run_server(token=token) as (server, channel, metadata_interceptor): 

300 jail_pb2_grpc.add_JailServicer_to_server(Jail(), server) 

301 yield jail_pb2_grpc.JailStub(channel) 

302 

303 

304@contextmanager 

305def real_stripe_session(): 

306 """ 

307 Create a Stripe service for testing, using TCP sockets 

308 """ 

309 with run_server() as (server, channel, metadata_interceptor): 

310 stripe_pb2_grpc.add_StripeServicer_to_server(Stripe(), server) 

311 yield stripe_pb2_grpc.StripeStub(channel) 

312 

313 

314@contextmanager 

315def real_iris_session(): 

316 with run_server() as (server, channel, metadata_interceptor): 

317 iris_pb2_grpc.add_IrisServicer_to_server(Iris(), server) 

318 yield iris_pb2_grpc.IrisStub(channel) 

319 

320 

321@contextmanager 

322def real_bugs_session(): 

323 """ 

324 Bugs over a real server so requests can carry metadata (HTTP request headers) 

325 and the response's initial metadata (HTTP response headers) can be asserted. 

326 """ 

327 with run_server() as (server, channel, metadata_interceptor): 

328 bugs_pb2_grpc.add_BugsServicer_to_server(Bugs(), server) 

329 yield bugs_pb2_grpc.BugsStub(channel), metadata_interceptor 

330 

331 

332@contextmanager 

333def media_session(bearer_token: str): 

334 """ 

335 Create a fresh Media API for testing, uses the bearer token for media auth 

336 """ 

337 media_auth_interceptor = get_media_auth_interceptor(bearer_token) 

338 

339 with futures.ThreadPoolExecutor(1) as executor: 

340 server = grpc.server(executor, interceptors=[media_auth_interceptor]) 

341 port = server.add_secure_port("localhost:0", grpc.local_server_credentials()) 

342 media_pb2_grpc.add_MediaServicer_to_server(Media(), server) 

343 server.start() 

344 

345 call_creds = grpc.access_token_call_credentials(bearer_token) 

346 comp_creds = grpc.composite_channel_credentials(grpc.local_channel_credentials(), call_creds) 

347 

348 try: 

349 with grpc.secure_channel(f"localhost:{port}", comp_creds) as channel: 

350 yield media_pb2_grpc.MediaStub(channel) 

351 finally: 

352 server.stop(None).wait() 

353 

354 

355# Sessions that don't need to start a real GRPC server. 

356# Note: these don't need to be context managers, but they are so that 

357# we can switch to a real implementation if needed. 

358@contextmanager 

359def api_session(token: str): 

360 """ 

361 Create an API for testing, uses the token for auth 

362 """ 

363 channel = FakeChannel(token) 

364 api_pb2_grpc.add_APIServicer_to_server(API(), channel) 

365 yield api_pb2_grpc.APIStub(channel) 

366 

367 

368@contextmanager 

369def gis_session(token: str): 

370 channel = FakeChannel(token) 

371 gis_pb2_grpc.add_GISServicer_to_server(GIS(), channel) 

372 yield gis_pb2_grpc.GISStub(channel) 

373 

374 

375@contextmanager 

376def public_session(): 

377 channel = FakeChannel() 

378 public_pb2_grpc.add_PublicServicer_to_server(Public(), channel) 

379 yield public_pb2_grpc.PublicStub(channel) 

380 

381 

382@contextmanager 

383def public_trips_session(token: str): 

384 channel = FakeChannel(token) 

385 public_trips_pb2_grpc.add_PublicTripsServicer_to_server(PublicTrips(), channel) 

386 yield public_trips_pb2_grpc.PublicTripsStub(channel) 

387 

388 

389@contextmanager 

390def conversations_session(token: str): 

391 """ 

392 Create a Conversations API for testing, uses the token for auth 

393 """ 

394 channel = FakeChannel(token) 

395 conversations_pb2_grpc.add_ConversationsServicer_to_server(Conversations(), channel) 

396 yield conversations_pb2_grpc.ConversationsStub(channel) 

397 

398 

399@contextmanager 

400def requests_session(token: str): 

401 """ 

402 Create a Requests API for testing, uses the token for auth 

403 """ 

404 channel = FakeChannel(token) 

405 requests_pb2_grpc.add_RequestsServicer_to_server(Requests(), channel) 

406 yield requests_pb2_grpc.RequestsStub(channel) 

407 

408 

409@contextmanager 

410def threads_session(token: str): 

411 channel = FakeChannel(token) 

412 threads_pb2_grpc.add_ThreadsServicer_to_server(Threads(), channel) 

413 yield threads_pb2_grpc.ThreadsStub(channel) 

414 

415 

416@contextmanager 

417def discussions_session(token: str): 

418 channel = FakeChannel(token) 

419 discussions_pb2_grpc.add_DiscussionsServicer_to_server(Discussions(), channel) 

420 yield discussions_pb2_grpc.DiscussionsStub(channel) 

421 

422 

423@contextmanager 

424def dashboard_session(token: str): 

425 channel = FakeChannel(token) 

426 dashboard_pb2_grpc.add_DashboardServicer_to_server(Dashboard(), channel) 

427 yield dashboard_pb2_grpc.DashboardStub(channel) 

428 

429 

430@contextmanager 

431def donations_session(token: str): 

432 channel = FakeChannel(token) 

433 donations_pb2_grpc.add_DonationsServicer_to_server(Donations(), channel) 

434 yield donations_pb2_grpc.DonationsStub(channel) 

435 

436 

437@contextmanager 

438def pages_session(token: str): 

439 channel = FakeChannel(token) 

440 pages_pb2_grpc.add_PagesServicer_to_server(Pages(), channel) 

441 yield pages_pb2_grpc.PagesStub(channel) 

442 

443 

444@contextmanager 

445def communities_session(token: str): 

446 channel = FakeChannel(token) 

447 communities_pb2_grpc.add_CommunitiesServicer_to_server(Communities(), channel) 

448 yield communities_pb2_grpc.CommunitiesStub(channel) 

449 

450 

451@contextmanager 

452def groups_session(token: str): 

453 channel = FakeChannel(token) 

454 groups_pb2_grpc.add_GroupsServicer_to_server(Groups(), channel) 

455 yield groups_pb2_grpc.GroupsStub(channel) 

456 

457 

458@contextmanager 

459def blocking_session(token: str): 

460 channel = FakeChannel(token) 

461 blocking_pb2_grpc.add_BlockingServicer_to_server(Blocking(), channel) 

462 yield blocking_pb2_grpc.BlockingStub(channel) 

463 

464 

465@contextmanager 

466def notifications_session(token: str): 

467 channel = FakeChannel(token) 

468 notifications_pb2_grpc.add_NotificationsServicer_to_server(Notifications(), channel) 

469 yield notifications_pb2_grpc.NotificationsStub(channel) 

470 

471 

472@contextmanager 

473def account_session(token: str): 

474 """ 

475 Create a Account API for testing, uses the token for auth 

476 """ 

477 channel = FakeChannel(token) 

478 account_pb2_grpc.add_AccountServicer_to_server(Account(), channel) 

479 yield account_pb2_grpc.AccountStub(channel) 

480 

481 

482@contextmanager 

483def search_session(token: str): 

484 """ 

485 Create a Search API for testing, uses the token for auth 

486 """ 

487 channel = FakeChannel(token) 

488 search_pb2_grpc.add_SearchServicer_to_server(Search(), channel) 

489 yield search_pb2_grpc.SearchStub(channel) 

490 

491 

492@contextmanager 

493def references_session(token: str): 

494 """ 

495 Create a References API for testing, uses the token for auth 

496 """ 

497 channel = FakeChannel(token) 

498 references_pb2_grpc.add_ReferencesServicer_to_server(References(), channel) 

499 yield references_pb2_grpc.ReferencesStub(channel) 

500 

501 

502@contextmanager 

503def galleries_session(token: str): 

504 """ 

505 Create a Galleries API for testing, uses the token for auth 

506 """ 

507 channel = FakeChannel(token) 

508 galleries_pb2_grpc.add_GalleriesServicer_to_server(Galleries(), channel) 

509 yield galleries_pb2_grpc.GalleriesStub(channel) 

510 

511 

512@contextmanager 

513def reporting_session(token: str): 

514 channel = FakeChannel(token) 

515 reporting_pb2_grpc.add_ReportingServicer_to_server(Reporting(), channel) 

516 yield reporting_pb2_grpc.ReportingStub(channel) 

517 

518 

519@contextmanager 

520def events_session(token: str): 

521 channel = FakeChannel(token) 

522 events_pb2_grpc.add_EventsServicer_to_server(Events(), channel) 

523 yield events_pb2_grpc.EventsStub(channel) 

524 

525 

526@contextmanager 

527def postal_verification_session(token: str): 

528 channel = FakeChannel(token) 

529 postal_verification_pb2_grpc.add_PostalVerificationServicer_to_server(PostalVerification(), channel) 

530 yield postal_verification_pb2_grpc.PostalVerificationStub(channel) 

531 

532 

533@contextmanager 

534def bugs_session(token: str | None = None): 

535 channel = FakeChannel(token) 

536 bugs_pb2_grpc.add_BugsServicer_to_server(Bugs(), channel) 

537 yield bugs_pb2_grpc.BugsStub(channel) 

538 

539 

540@contextmanager 

541def resources_session(*, locale: str | None = None): 

542 channel = FakeChannel(locale=locale) 

543 resources_pb2_grpc.add_ResourcesServicer_to_server(Resources(), channel) 

544 yield resources_pb2_grpc.ResourcesStub(channel)