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

783 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1from collections.abc import Callable, Generator 

2from concurrent import futures 

3from contextlib import contextmanager 

4from datetime import timedelta 

5from threading import current_thread 

6from typing import Any 

7from unittest.mock import Mock, patch 

8 

9import grpc 

10import pytest 

11from google.protobuf import empty_pb2 

12from google.protobuf.descriptor import ServiceDescriptor 

13from google.protobuf.descriptor_pool import DescriptorPool 

14from sqlalchemy import select, text, update 

15 

16from couchers.constants import ( 

17 COOKIES_AND_AUTH_HEADER_ERROR_MESSAGE, 

18 MISSING_AUTH_LEVEL_ERROR_MESSAGE, 

19 NONEXISTENT_API_CALL_ERROR_MESSAGE, 

20 UNKNOWN_ERROR_MESSAGE, 

21) 

22from couchers.crypto import b64encode, random_hex, simple_encrypt 

23from couchers.db import session_scope 

24from couchers.metrics import ( 

25 api_calls_counter, 

26 servicer_db_query_count_histogram, 

27 servicer_duration_histogram, 

28 servicer_pool_wait_histogram, 

29 servicer_serde_histogram, 

30 servicer_setup_cpu_time_histogram, 

31 servicer_setup_db_time_histogram, 

32 servicer_setup_errors_counter, 

33) 

34from couchers.middleware.errors import CallRejectedError 

35from couchers.middleware.interceptors import ( 

36 NONEXISTENT_METHOD_LABEL, 

37 BadHeaders, 

38 CouchersMiddlewareInterceptor, 

39 ErrorSanitizationInterceptor, 

40 UserAuthInfo, 

41 _try_get_and_update_user_details, 

42 check_permissions, 

43 parse_headers, 

44) 

45from couchers.middleware.proto_annotations import ProtoAnnotations, get_proto_annotations, validate_auth_level 

46from couchers.models import APICall, ClientPlatform, User, UserActivity, UserSession 

47from couchers.proto import account_pb2, admin_pb2, annotations_pb2, api_pb2, auth_pb2 

48from couchers.servicers.account import Account 

49from couchers.servicers.api import API 

50from couchers.utils import generate_sofa_cookie, now, parse_sofa_cookie 

51from tests.fixtures.db import generate_user 

52from tests.fixtures.sessions import real_admin_session 

53 

54 

55@contextmanager 

56def interceptor_dummy_api( 

57 rpc, 

58 interceptors, 

59 service_name="org.couchers.auth.Auth", 

60 method_name="SignupFlow", 

61 request_type=empty_pb2.Empty, 

62 response_type=empty_pb2.Empty, 

63 creds=None, 

64 call_method_name=None, 

65) -> Generator[Callable[..., Any]]: 

66 with futures.ThreadPoolExecutor(1) as executor: 

67 server = grpc.server(executor, interceptors=interceptors) 

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

69 

70 # manually add the handler 

71 rpc_method_handlers = { 

72 method_name: grpc.unary_unary_rpc_method_handler( 

73 rpc, 

74 request_deserializer=request_type.FromString, 

75 response_serializer=response_type.SerializeToString, 

76 ) 

77 } 

78 generic_handler = grpc.method_handlers_generic_handler(service_name, rpc_method_handlers) 

79 server.add_generic_rpc_handlers((generic_handler,)) 

80 server.start() 

81 

82 try: 

83 with grpc.secure_channel(f"localhost:{port}", creds or grpc.local_channel_credentials()) as channel: 

84 yield channel.unary_unary( 

85 f"/{service_name}/{call_method_name or method_name}", 

86 request_serializer=request_type.SerializeToString, 

87 response_deserializer=response_type.FromString, 

88 ) 

89 finally: 

90 server.stop(None).wait() 

91 

92 

93def _get_histogram_labels_value(method, logged_in, exception, code): 

94 metrics = servicer_duration_histogram.collect() 

95 servicer_histogram = [m for m in metrics if m.name == "couchers_servicer_duration_seconds"][0] 

96 histogram_counts = [ 

97 s 

98 for s in servicer_histogram.samples 

99 if s.name == "couchers_servicer_duration_seconds_count" 

100 and s.labels["method"] == method 

101 and s.labels["logged_in"] == logged_in 

102 and s.labels["code"] == code 

103 and s.labels["exception"] == exception 

104 ] 

105 if len(histogram_counts) == 0: 

106 return 0 

107 return histogram_counts[0].value 

108 

109 

110def _get_setup_errors_value(method, exception): 

111 metrics = servicer_setup_errors_counter.collect() 

112 counter = [m for m in metrics if m.name == "couchers_servicer_setup_errors"][0] 

113 samples = [ 

114 s 

115 for s in counter.samples 

116 if s.name == "couchers_servicer_setup_errors_total" 

117 and s.labels["method"] == method 

118 and s.labels["exception"] == exception 

119 ] 

120 if len(samples) == 0: 

121 return 0 

122 return samples[0].value 

123 

124 

125def test_logging_interceptor_ok(): 

126 def TestRpc(request, context): 

127 return empty_pb2.Empty() 

128 

129 with interceptor_dummy_api(TestRpc, interceptors=[ErrorSanitizationInterceptor()]) as call_rpc: 

130 call_rpc(empty_pb2.Empty()) 

131 

132 

133def test_logging_interceptor_all_ignored(): 

134 # error codes that should not be touched by the interceptor 

135 pass_through_status_codes = [ 

136 # we can't abort with OK 

137 # grpc.StatusCode.OK, 

138 grpc.StatusCode.CANCELLED, 

139 grpc.StatusCode.UNKNOWN, 

140 grpc.StatusCode.INVALID_ARGUMENT, 

141 grpc.StatusCode.DEADLINE_EXCEEDED, 

142 grpc.StatusCode.NOT_FOUND, 

143 grpc.StatusCode.ALREADY_EXISTS, 

144 grpc.StatusCode.PERMISSION_DENIED, 

145 grpc.StatusCode.UNAUTHENTICATED, 

146 grpc.StatusCode.RESOURCE_EXHAUSTED, 

147 grpc.StatusCode.FAILED_PRECONDITION, 

148 grpc.StatusCode.ABORTED, 

149 grpc.StatusCode.OUT_OF_RANGE, 

150 grpc.StatusCode.UNIMPLEMENTED, 

151 grpc.StatusCode.INTERNAL, 

152 grpc.StatusCode.UNAVAILABLE, 

153 grpc.StatusCode.DATA_LOSS, 

154 ] 

155 

156 for status_code in pass_through_status_codes: 

157 message = random_hex() 

158 

159 def TestRpc(request, context): 

160 context.abort(status_code, message) # noqa: B023 

161 

162 with interceptor_dummy_api(TestRpc, interceptors=[ErrorSanitizationInterceptor()]) as call_rpc: 

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

164 call_rpc(empty_pb2.Empty()) 

165 assert e.value.code() == status_code 

166 assert e.value.details() == message 

167 

168 

169def test_logging_interceptor_assertion(): 

170 def TestRpc(request, context): 

171 raise AssertionError() 

172 

173 with interceptor_dummy_api(TestRpc, interceptors=[ErrorSanitizationInterceptor()]) as call_rpc: 

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

175 call_rpc(empty_pb2.Empty()) 

176 assert e.value.code() == grpc.StatusCode.INTERNAL 

177 assert e.value.details() == "An unknown backend error occurred. Please consider filing a bug!" 

178 

179 

180def test_logging_interceptor_div0(): 

181 def TestRpc(request, context): 

182 1 / 0 # noqa: B018 

183 

184 with interceptor_dummy_api(TestRpc, interceptors=[ErrorSanitizationInterceptor()]) as call_rpc: 

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

186 call_rpc(empty_pb2.Empty()) 

187 assert e.value.code() == grpc.StatusCode.INTERNAL 

188 assert e.value.details() == "An unknown backend error occurred. Please consider filing a bug!" 

189 

190 

191def test_logging_interceptor_raise(): 

192 def TestRpc(request, context): 

193 raise Exception() 

194 

195 with interceptor_dummy_api(TestRpc, interceptors=[ErrorSanitizationInterceptor()]) as call_rpc: 

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

197 call_rpc(empty_pb2.Empty()) 

198 assert e.value.code() == grpc.StatusCode.INTERNAL 

199 assert e.value.details() == "An unknown backend error occurred. Please consider filing a bug!" 

200 

201 

202def test_logging_interceptor_raise_custom(): 

203 class _TestingException(Exception): 

204 pass 

205 

206 def TestRpc(request, context): 

207 raise _TestingException("This is a custom exception") 

208 

209 with interceptor_dummy_api(TestRpc, interceptors=[ErrorSanitizationInterceptor()]) as call_rpc: 

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

211 call_rpc(empty_pb2.Empty()) 

212 assert e.value.code() == grpc.StatusCode.INTERNAL 

213 assert e.value.details() == "An unknown backend error occurred. Please consider filing a bug!" 

214 

215 

216def test_tracing_interceptor_ok_open(db): 

217 val = _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "", "") 

218 

219 def TestRpc(request, context, session): 

220 return empty_pb2.Empty() 

221 

222 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

223 call_rpc(empty_pb2.Empty()) 

224 

225 with session_scope() as session: 

226 trace = session.execute(select(APICall)).scalar_one() 

227 assert trace.method == "/org.couchers.auth.Auth/SignupFlow" 

228 assert not trace.status_code 

229 assert not trace.user_id 

230 assert trace.request is not None 

231 assert len(trace.request) == 0 

232 assert trace.response is not None 

233 assert len(trace.response) == 0 

234 assert not trace.traceback 

235 

236 assert _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "", "") == val + 1 

237 

238 

239def _get_db_query_count_histogram(method): 

240 return sum( 

241 s.value 

242 for m in servicer_db_query_count_histogram.collect() 

243 for s in m.samples 

244 if s.name == "couchers_servicer_db_query_count_count" and s.labels.get("method") == method 

245 ) 

246 

247 

248def _get_api_call_count(method, platform): 

249 return sum( 

250 s.value 

251 for m in api_calls_counter.collect() 

252 for s in m.samples 

253 if s.name == "couchers_api_calls_total" 

254 and s.labels.get("method") == method 

255 and s.labels.get("platform") == platform 

256 ) 

257 

258 

259def test_tracing_interceptor_perf_accounting(db): 

260 method = "/org.couchers.auth.Auth/SignupFlow" 

261 hist_count_before = _get_db_query_count_histogram(method) 

262 api_call_count_before = _get_api_call_count(method, "web_mobile") 

263 

264 # handler runs a known number of statements: three reads and one compiled write. The write matches zero rows so 

265 # it's side-effect free. 

266 def TestRpc(request, context, session): 

267 for _ in range(3): 

268 session.execute(text("SELECT 1")) 

269 session.execute(update(APICall).where(APICall.id == -1).values(method="x")) 

270 return empty_pb2.Empty() 

271 

272 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

273 call_rpc(empty_pb2.Empty(), metadata=(("x-couchers-client-platform", "web_mobile"),)) 

274 

275 with session_scope() as session: 

276 trace = session.execute(select(APICall)).scalar_one() 

277 assert trace.db_query_count == 4 

278 assert trace.db_write_query_count == 1 

279 assert trace.db_time_ms is not None and trace.db_time_ms >= 0 

280 assert trace.cpu_ms is not None and trace.cpu_ms >= 0 

281 # the handler's DB work can't exceed the whole-request wall time 

282 assert trace.db_time_ms <= trace.duration 

283 assert trace.client_platform == ClientPlatform.web_mobile 

284 

285 # the call was also observed into the Prometheus per-request resource histograms and the per-platform call counter 

286 assert _get_db_query_count_histogram(method) == hist_count_before + 1 

287 assert _get_api_call_count(method, "web_mobile") == api_call_count_before + 1 

288 

289 

290def _get_histogram_count(histogram, count_name, **labels): 

291 return sum( 

292 s.value 

293 for m in histogram.collect() 

294 for s in m.samples 

295 if s.name == count_name and all(s.labels.get(k) == v for k, v in labels.items()) 

296 ) 

297 

298 

299def test_tracing_interceptor_phase_histograms(db): 

300 # setup db/cpu, pool-wait, and de/serialization are each observed once per call into their own histogram 

301 method = "/org.couchers.auth.Auth/SignupFlow" 

302 setup_db_before = _get_histogram_count( 

303 servicer_setup_db_time_histogram, "couchers_servicer_setup_db_time_seconds_count", method=method 

304 ) 

305 setup_cpu_before = _get_histogram_count( 

306 servicer_setup_cpu_time_histogram, "couchers_servicer_setup_cpu_seconds_count", method=method 

307 ) 

308 pool_wait_before = _get_histogram_count( 

309 servicer_pool_wait_histogram, "couchers_servicer_pool_wait_seconds_count", method=method 

310 ) 

311 deserialize_before = _get_histogram_count( 

312 servicer_serde_histogram, "couchers_servicer_serde_seconds_count", method=method, direction="deserialize" 

313 ) 

314 serialize_before = _get_histogram_count( 

315 servicer_serde_histogram, "couchers_servicer_serde_seconds_count", method=method, direction="serialize" 

316 ) 

317 

318 def TestRpc(request, context, session): 

319 return empty_pb2.Empty() 

320 

321 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

322 call_rpc(empty_pb2.Empty()) 

323 

324 assert ( 

325 _get_histogram_count( 

326 servicer_setup_db_time_histogram, "couchers_servicer_setup_db_time_seconds_count", method=method 

327 ) 

328 == setup_db_before + 1 

329 ) 

330 assert ( 

331 _get_histogram_count( 

332 servicer_setup_cpu_time_histogram, "couchers_servicer_setup_cpu_seconds_count", method=method 

333 ) 

334 == setup_cpu_before + 1 

335 ) 

336 assert ( 

337 _get_histogram_count(servicer_pool_wait_histogram, "couchers_servicer_pool_wait_seconds_count", method=method) 

338 == pool_wait_before + 1 

339 ) 

340 assert ( 

341 _get_histogram_count( 

342 servicer_serde_histogram, "couchers_servicer_serde_seconds_count", method=method, direction="deserialize" 

343 ) 

344 == deserialize_before + 1 

345 ) 

346 assert ( 

347 _get_histogram_count( 

348 servicer_serde_histogram, "couchers_servicer_serde_seconds_count", method=method, direction="serialize" 

349 ) 

350 == serialize_before + 1 

351 ) 

352 

353 

354def test_auth_runs_on_the_handler_thread(db): 

355 # gRPC runs intercept_service inline on the server's single polling thread, under the server-wide lock, so the auth 

356 # query has to happen in the returned handler or it serializes dispatch for every other call in the process 

357 threads: dict[str, str] = {} 

358 

359 def TestRpc(request, context, session): 

360 threads["handler"] = current_thread().name 

361 return empty_pb2.Empty() 

362 

363 def record_thread(*args, **kwargs): 

364 threads["auth"] = current_thread().name 

365 return _try_get_and_update_user_details(*args, **kwargs) 

366 

367 with ( 

368 patch("couchers.middleware.interceptors._try_get_and_update_user_details", record_thread), 

369 interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc, 

370 ): 

371 call_rpc(empty_pb2.Empty()) 

372 

373 assert threads["auth"] == threads["handler"] 

374 

375 

376def test_tracing_interceptor_perf_accounting_orm_write(db): 

377 # a handler that only session.add(...)s and returns: the INSERT flushes at commit, after read_perf(), so without 

378 # the interceptor's explicit flush it would be missed from the write/query counts 

379 method = "/org.couchers.auth.Auth/SignupFlow" 

380 

381 def TestRpc(request, context, session): 

382 session.add(APICall(method="handler-insert", duration=0.0, is_api_key=False, response_truncated=False)) 

383 return empty_pb2.Empty() 

384 

385 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

386 call_rpc(empty_pb2.Empty()) 

387 

388 with session_scope() as session: 

389 log = session.execute(select(APICall).where(APICall.method == method)).scalar_one() 

390 assert log.db_query_count == 1 

391 assert log.db_write_query_count == 1 

392 

393 

394def test_tracing_interceptor_sensitive(db): 

395 val = _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "", "") 

396 

397 def TestRpc(request, context, session): 

398 return auth_pb2.AuthReq(user="this is not secret", password="this is secret") 

399 

400 with interceptor_dummy_api( 

401 TestRpc, 

402 interceptors=[CouchersMiddlewareInterceptor()], 

403 request_type=auth_pb2.SignupFlowReq, 

404 response_type=auth_pb2.AuthReq, 

405 ) as call_rpc: 

406 call_rpc( 

407 auth_pb2.SignupFlowReq(account=auth_pb2.SignupAccount(password="should be removed", username="not removed")) 

408 ) 

409 

410 with session_scope() as session: 

411 trace = session.execute(select(APICall)).scalar_one() 

412 assert trace.method == "/org.couchers.auth.Auth/SignupFlow" 

413 assert not trace.status_code 

414 assert not trace.user_id 

415 assert not trace.traceback 

416 assert trace.request is not None 

417 req = auth_pb2.SignupFlowReq.FromString(trace.request) 

418 assert not req.account.password 

419 assert req.account.username == "not removed" 

420 assert trace.response 

421 res = auth_pb2.AuthReq.FromString(trace.response) 

422 assert res.user == "this is not secret" 

423 assert not res.password 

424 

425 assert _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "", "") == val + 1 

426 

427 

428def test_tracing_interceptor_sensitive_ping(db): 

429 user, token = generate_user() 

430 

431 with interceptor_dummy_api( 

432 API().GetUser, 

433 interceptors=[CouchersMiddlewareInterceptor()], 

434 request_type=api_pb2.GetUserReq, 

435 response_type=api_pb2.User, 

436 service_name="org.couchers.api.core.API", 

437 method_name="GetUser", 

438 ) as call_rpc: 

439 call_rpc(api_pb2.GetUserReq(user=user.username), metadata=(("cookie", f"couchers-sesh={token}"),)) 

440 

441 

442def test_tracing_interceptor_exception(db): 

443 val = _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "Exception", "") 

444 

445 def TestRpc(request, context, session): 

446 raise Exception("Some error message") 

447 

448 with interceptor_dummy_api( 

449 TestRpc, 

450 interceptors=[CouchersMiddlewareInterceptor()], 

451 request_type=auth_pb2.SignupAccount, 

452 response_type=auth_pb2.AuthReq, 

453 ) as call_rpc: 

454 with pytest.raises(Exception, match="Some error message"): 

455 call_rpc(auth_pb2.SignupAccount(password="should be removed", username="not removed")) 

456 

457 with session_scope() as session: 

458 trace = session.execute(select(APICall)).scalar_one() 

459 assert trace.method == "/org.couchers.auth.Auth/SignupFlow" 

460 assert not trace.status_code 

461 assert not trace.user_id 

462 assert trace.traceback 

463 assert "Some error message" in trace.traceback 

464 assert trace.request is not None 

465 req = auth_pb2.SignupAccount.FromString(trace.request) 

466 assert not req.password 

467 assert req.username == "not removed" 

468 assert not trace.response 

469 

470 assert _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "Exception", "") == val + 1 

471 

472 

473def test_setup_phase_exception_observed(db): 

474 method = "/org.couchers.auth.Auth/SignupFlow" 

475 val = _get_setup_errors_value(method, "ValueError") 

476 

477 def TestRpc(request, context, session): 

478 return empty_pb2.Empty() 

479 

480 with ( 

481 patch("couchers.middleware.interceptors.LocalizationContext", side_effect=ValueError("expected only letters")), 

482 patch("couchers.middleware.interceptors.sentry_sdk") as mock_sentry, 

483 interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc, 

484 ): 

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

486 call_rpc(empty_pb2.Empty()) 

487 assert e.value.code() == grpc.StatusCode.INTERNAL 

488 assert e.value.details() == UNKNOWN_ERROR_MESSAGE 

489 mock_sentry.capture_exception.assert_called_once() 

490 

491 assert _get_setup_errors_value(method, "ValueError") == val + 1 

492 

493 with session_scope() as session: 

494 trace = session.execute(select(APICall)).scalar_one() 

495 assert trace.method == method 

496 assert trace.status_code == "INTERNAL" 

497 assert trace.traceback 

498 assert "expected only letters" in trace.traceback 

499 

500 

501def test_tracing_interceptor_abort(db): 

502 val = _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "Exception", "FAILED_PRECONDITION") 

503 

504 def TestRpc(request, context, session): 

505 context.abort(grpc.StatusCode.FAILED_PRECONDITION, "now a grpc abort") 

506 

507 with interceptor_dummy_api( 

508 TestRpc, 

509 interceptors=[CouchersMiddlewareInterceptor()], 

510 request_type=auth_pb2.SignupAccount, 

511 response_type=auth_pb2.AuthReq, 

512 ) as call_rpc: 

513 with pytest.raises(Exception, match="now a grpc abort"): 

514 call_rpc(auth_pb2.SignupAccount(password="should be removed", username="not removed")) 

515 

516 with session_scope() as session: 

517 trace = session.execute(select(APICall)).scalar_one() 

518 assert trace.method == "/org.couchers.auth.Auth/SignupFlow" 

519 assert trace.status_code == "FAILED_PRECONDITION" 

520 assert not trace.user_id 

521 assert trace.traceback 

522 assert "now a grpc abort" in trace.traceback 

523 assert trace.request is not None 

524 req = auth_pb2.SignupAccount.FromString(trace.request) 

525 assert not req.password 

526 assert req.username == "not removed" 

527 assert not trace.response 

528 

529 assert ( 

530 _get_histogram_labels_value("/org.couchers.auth.Auth/SignupFlow", "False", "Exception", "FAILED_PRECONDITION") 

531 == val + 1 

532 ) 

533 

534 

535def cookie_auth(token: str) -> tuple[str, str]: 

536 return "cookie", f"couchers-sesh={token}" 

537 

538 

539def api_auth(token: str) -> tuple[str, str]: 

540 return "authorization", f"Bearer {token}" 

541 

542 

543def test_auth_interceptor(db): 

544 super_user, super_token = generate_user(is_superuser=True) 

545 user, token = generate_user() 

546 deleted_user, deleted_token = generate_user(delete_user=True) 

547 

548 with real_admin_session(super_token) as api: 

549 api.CreateApiKey(admin_pb2.CreateApiKeyReq(user=user.username)) 

550 

551 with session_scope() as session: 

552 api_key = session.execute(select(UserSession.token).where(UserSession.is_api_key)).scalar_one() 

553 

554 account = Account() 

555 

556 rpc_def = { 

557 "rpc": account.GetAccountInfo, 

558 "service_name": "org.couchers.api.account.Account", 

559 "method_name": "GetAccountInfo", 

560 "interceptors": [CouchersMiddlewareInterceptor()], 

561 "request_type": empty_pb2.Empty, 

562 "response_type": account_pb2.GetAccountInfoRes, 

563 } 

564 

565 # no creds, no-go for secure APIs 

566 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

568 call_rpc(empty_pb2.Empty()) 

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

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

571 

572 # can auth with cookie 

573 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

574 res1 = call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

575 assert res1.username == user.username 

576 

577 with session_scope() as session: 

578 api_calls = session.execute(select(UserActivity.api_calls).where(UserActivity.user_id == user.id)).scalar_one() 

579 assert api_calls == 1 

580 

581 # can't auth with a wrong cookie 

582 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

584 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(random_hex(32)),)) 

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

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

587 

588 # can auth with an api key 

589 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

590 res2 = call_rpc(empty_pb2.Empty(), metadata=(api_auth(api_key),)) 

591 assert res2.username == user.username 

592 

593 with session_scope() as session: 

594 api_calls = session.execute(select(UserActivity.api_calls).where(UserActivity.user_id == user.id)).scalar_one() 

595 assert api_calls == 2 

596 

597 # can't auth with a wrong api key 

598 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

600 call_rpc(empty_pb2.Empty(), metadata=(api_auth(random_hex(32)),)) 

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

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

603 

604 # can auth with grpc helper (they do the same as above) 

605 comp_creds = grpc.composite_channel_credentials( 

606 grpc.local_channel_credentials(), grpc.access_token_call_credentials(api_key) 

607 ) 

608 with interceptor_dummy_api(**rpc_def, creds=comp_creds) as call_rpc: 

609 res3 = call_rpc(empty_pb2.Empty()) 

610 assert res3.username == user.username 

611 

612 # can't auth with both 

613 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

615 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token), api_auth(api_key))) 

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

617 assert e.value.details() == 'Both "cookie" and "authorization" in request' 

618 

619 # malformed bearer 

620 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

622 call_rpc(empty_pb2.Empty(), metadata=(("authorization", f"bearer {api_key}"),)) 

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

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

625 

626 # Invisible (deleted) user 

627 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

629 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(deleted_token),)) 

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

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

632 

633 # Invalid (expired) session 

634 long_ago = now() - timedelta(weeks=100) 

635 with session_scope() as session: 

636 session.execute(update(UserSession).values(last_seen=long_ago).where(UserSession.token == token)) 

637 

638 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

640 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

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

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

643 

644 # API key token, but session is for session cookie (probably impossible, but...) 

645 with session_scope() as session: 

646 session.execute(update(UserSession).values(last_seen=now(), is_api_key=True).where(UserSession.token == token)) 

647 

648 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

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

650 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

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

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

653 

654 # Check that metadata are updated 

655 six_minutes_ago = now() - timedelta(minutes=6) 

656 with session_scope() as session: 

657 # Return the session to normal 

658 user_session = session.execute(select(UserSession).where(UserSession.token == token)).scalar_one() 

659 user_session.is_api_key = False 

660 api_calls = user_session.api_calls 

661 

662 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

663 res4 = call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

664 assert res4.username == user.username 

665 

666 with session_scope() as session: 

667 user_session = session.execute(select(UserSession).where(UserSession.token == token)).scalar_one() 

668 assert user_session.api_calls == api_calls + 1 

669 assert user_session.last_seen > now() - timedelta(seconds=1) 

670 

671 # Simulate user inactivity, so last_active is updated on the next api call. 

672 session.execute(update(User).values(last_active=six_minutes_ago).where(User.id == user.id)) 

673 

674 # Check that last_active is updated if it wasn't updated in a while. 

675 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

676 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

677 

678 with session_scope() as session: 

679 last_active = session.execute(select(User.last_active).where(User.id == user.id)).scalar_one() 

680 assert last_active > now() - timedelta(seconds=1) 

681 

682 # Check that last_active is untouched (since it was already updated recently) 

683 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

684 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

685 

686 with session_scope() as session: 

687 last_active_2 = session.execute(select(User.last_active).where(User.id == user.id)).scalar_one() 

688 assert last_active_2 == last_active 

689 

690 # Check that activity is split by IP. 

691 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

692 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token), ("x-couchers-real-ip", "1.1.1.1"))) 

693 

694 with session_scope() as session: 

695 api_calls = session.execute( 

696 select(UserActivity.api_calls).where(UserActivity.ip_address == "1.1.1.1") 

697 ).scalar_one() 

698 assert api_calls == 1 

699 

700 # Check that activity is split in time bins. 

701 # Update all UserActivity to be in the far past so that a new row is inserted on the next request. 

702 with session_scope() as session: 

703 session.execute(update(UserActivity).values(period=long_ago).where(UserActivity.user_id == user.id)) 

704 

705 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

706 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token),)) 

707 

708 with session_scope() as session: 

709 api_calls = session.execute( 

710 select(UserActivity.api_calls) 

711 .where(UserActivity.user_id == user.id) 

712 .order_by(UserActivity.id.desc()) 

713 .limit(1) 

714 ).scalar_one() 

715 assert api_calls == 1 

716 

717 

718def test_tracing_interceptor_auth_cookies(db): 

719 user, token = generate_user() 

720 

721 account = Account() 

722 

723 rpc_def = { 

724 "rpc": account.GetAccountInfo, 

725 "service_name": "org.couchers.api.account.Account", 

726 "method_name": "GetAccountInfo", 

727 "interceptors": [CouchersMiddlewareInterceptor()], 

728 "request_type": empty_pb2.Empty, 

729 "response_type": account_pb2.GetAccountInfoRes, 

730 } 

731 

732 # with cookies 

733 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

734 res1 = call_rpc(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}"),)) 

735 assert res1.username == user.username 

736 

737 with session_scope() as session: 

738 trace = session.execute(select(APICall)).scalar_one() 

739 assert trace.method == "/org.couchers.api.account.Account/GetAccountInfo" 

740 assert not trace.status_code 

741 assert trace.user_id == user.id 

742 assert not trace.is_api_key 

743 assert trace.request is not None 

744 assert len(trace.request) == 0 

745 assert not trace.traceback 

746 

747 

748def test_tracing_interceptor_auth_api_key(db): 

749 super_user, super_token = generate_user(is_superuser=True) 

750 user, token = generate_user() 

751 

752 with real_admin_session(super_token) as api: 

753 api.CreateApiKey(admin_pb2.CreateApiKeyReq(user=user.username)) 

754 

755 with session_scope() as session: 

756 api_key = session.execute(select(UserSession.token).where(UserSession.is_api_key)).scalar_one() 

757 

758 account = Account() 

759 

760 rpc_def = { 

761 "rpc": account.GetAccountInfo, 

762 "service_name": "org.couchers.api.account.Account", 

763 "method_name": "GetAccountInfo", 

764 "interceptors": [CouchersMiddlewareInterceptor()], 

765 "request_type": empty_pb2.Empty, 

766 "response_type": account_pb2.GetAccountInfoRes, 

767 } 

768 

769 # with api key 

770 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

771 res1 = call_rpc(empty_pb2.Empty(), metadata=(("authorization", f"Bearer {api_key}"),)) 

772 assert res1.username == user.username 

773 

774 with session_scope() as session: 

775 trace = session.execute( 

776 select(APICall).where(APICall.method == "/org.couchers.api.account.Account/GetAccountInfo") 

777 ).scalar_one() 

778 assert trace.method == "/org.couchers.api.account.Account/GetAccountInfo" 

779 assert not trace.status_code 

780 assert trace.user_id == user.id 

781 assert trace.is_api_key 

782 assert trace.request is not None 

783 assert len(trace.request) == 0 

784 assert not trace.traceback 

785 

786 

787def test_auth_levels(db): 

788 def TestRpc(request, context, session): 

789 return empty_pb2.Empty() 

790 

791 def gen_args(service, method): 

792 return { 

793 "rpc": TestRpc, 

794 "service_name": service, 

795 "method_name": method, 

796 "interceptors": [CouchersMiddlewareInterceptor()], 

797 "request_type": empty_pb2.Empty, 

798 "response_type": empty_pb2.Empty, 

799 } 

800 

801 # superuser (note: superusers are automatically editors due to DB constraint) 

802 _, super_token = generate_user(is_superuser=True) 

803 # editor user 

804 _, editor_token = generate_user(is_editor=True) 

805 # normal user 

806 _, normal_token = generate_user() 

807 # jailed user 

808 _, jailed_token = generate_user(accepted_tos=0) 

809 # open user 

810 open_token = "" 

811 

812 # pick some rpcs here with the right auth levels 

813 open_args = gen_args("org.couchers.resources.Resources", "GetTermsOfService") 

814 jailed_args = gen_args("org.couchers.jail.Jail", "JailInfo") 

815 secure_args = gen_args("org.couchers.api.account.Account", "GetAccountInfo") 

816 editor_args = gen_args("org.couchers.editor.Editor", "CreateCommunity") 

817 admin_args = gen_args("org.couchers.admin.Admin", "GetUserDetails") 

818 

819 # pairs to check 

820 checks = [ 

821 # name, args, token, works?, code, message 

822 # open token only works on open servicers 

823 ("open x open", open_token, open_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

824 ("open x jailed", open_token, jailed_args, False, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

825 ("open x secure", open_token, secure_args, False, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

826 ("open x editor", open_token, editor_args, False, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

827 ("open x admin", open_token, admin_args, False, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

828 # jailed works on jailed and open 

829 ("jailed x open", jailed_token, open_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

830 ("jailed x jailed", jailed_token, jailed_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

831 ("jailed x secure", jailed_token, secure_args, False, grpc.StatusCode.UNAUTHENTICATED, "Permission denied"), 

832 ("jailed x editor", jailed_token, editor_args, False, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

833 ("jailed x admin", jailed_token, admin_args, False, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

834 # normal works on all but editor and admin 

835 ("normal x open", normal_token, open_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

836 ("normal x jailed", normal_token, jailed_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

837 ("normal x secure", normal_token, secure_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

838 ("normal x editor", normal_token, editor_args, False, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

839 ("normal x admin", normal_token, admin_args, False, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

840 # editor works on all but admin 

841 ("editor x open", editor_token, open_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

842 ("editor x jailed", editor_token, jailed_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

843 ("editor x secure", editor_token, secure_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

844 ("editor x editor", editor_token, editor_args, True, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

845 ("editor x admin", editor_token, admin_args, False, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

846 # superuser works on all 

847 ("super x open", super_token, open_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

848 ("super x jailed", super_token, jailed_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

849 ("super x secure", super_token, secure_args, True, grpc.StatusCode.UNAUTHENTICATED, "Unauthorized"), 

850 ("super x editor", super_token, editor_args, True, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

851 ("super x admin", super_token, admin_args, True, grpc.StatusCode.PERMISSION_DENIED, "Permission denied"), 

852 ] 

853 

854 for name, token, args, should_work, code, message in checks: 

855 print(f"Testing (token x args) = ({name}), {should_work=}") 

856 metadata = (("cookie", f"couchers-sesh={token}"),) 

857 with interceptor_dummy_api(**args) as call_rpc: 

858 if should_work: 

859 call_rpc(empty_pb2.Empty(), metadata=metadata) 

860 else: 

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

862 call_rpc(empty_pb2.Empty(), metadata=metadata) 

863 assert err.value.code() == code 

864 assert err.value.details() == message 

865 

866 # a non-existent RPC 

867 nonexistent = gen_args("org.couchers.nonexistent.NA", "GetNothing") 

868 

869 with interceptor_dummy_api(**nonexistent) as call_rpc: 

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

871 call_rpc(empty_pb2.Empty()) 

872 assert err.value.code() == grpc.StatusCode.UNIMPLEMENTED 

873 assert err.value.details() == "API call does not exist. Please refresh and try again." 

874 

875 # an RPC without a service level 

876 invalid_args = gen_args("org.couchers.media.Media", "UploadConfirmation") 

877 

878 with interceptor_dummy_api(**invalid_args) as call_rpc: 

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

880 call_rpc(empty_pb2.Empty()) 

881 assert err.value.code() == grpc.StatusCode.INTERNAL 

882 assert err.value.details() == "Internal authentication error." 

883 

884 

885def test_rejected_call_logged_unauthenticated(db): 

886 method = "/org.couchers.api.account.Account/GetAccountInfo" 

887 hist_before = _get_histogram_labels_value(method, "False", "", "UNAUTHENTICATED") 

888 api_calls_before = _get_api_call_count(method, "unknown") 

889 

890 def TestRpc(request, context, session): 

891 return empty_pb2.Empty() 

892 

893 with interceptor_dummy_api( 

894 TestRpc, 

895 interceptors=[CouchersMiddlewareInterceptor()], 

896 service_name="org.couchers.api.account.Account", 

897 method_name="GetAccountInfo", 

898 ) as call_rpc: 

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

900 call_rpc(empty_pb2.Empty()) 

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

902 

903 with session_scope() as session: 

904 trace = session.execute(select(APICall)).scalar_one() 

905 assert trace.method == method 

906 assert trace.status_code == "UNAUTHENTICATED" 

907 assert trace.user_id is None 

908 assert not trace.is_api_key 

909 # the request is never deserialized on a reject path 

910 assert trace.request is None 

911 assert trace.response is None 

912 assert not trace.traceback 

913 assert trace.duration > 0 

914 

915 assert _get_histogram_labels_value(method, "False", "", "UNAUTHENTICATED") == hist_before + 1 

916 assert _get_api_call_count(method, "unknown") == api_calls_before + 1 

917 

918 

919def test_rejected_call_logged_permission_denied(db): 

920 """A rejected call from a valid session is attributed to that user.""" 

921 user, token = generate_user() 

922 method = "/org.couchers.admin.Admin/GetUserDetails" 

923 hist_before = _get_histogram_labels_value(method, "True", "", "PERMISSION_DENIED") 

924 

925 def TestRpc(request, context, session): 

926 return empty_pb2.Empty() 

927 

928 with interceptor_dummy_api( 

929 TestRpc, 

930 interceptors=[CouchersMiddlewareInterceptor()], 

931 service_name="org.couchers.admin.Admin", 

932 method_name="GetUserDetails", 

933 ) as call_rpc: 

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

935 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token), ("x-couchers-client-platform", "web_mobile"))) 

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

937 

938 with session_scope() as session: 

939 trace = session.execute(select(APICall)).scalar_one() 

940 assert trace.method == method 

941 assert trace.status_code == "PERMISSION_DENIED" 

942 assert trace.user_id == user.id 

943 assert trace.client_platform == ClientPlatform.web_mobile 

944 assert not trace.traceback 

945 

946 # the same call is also counted in the per-user activity table, so the two agree 

947 api_calls = session.execute(select(UserActivity.api_calls).where(UserActivity.user_id == user.id)).scalar_one() 

948 assert api_calls == 1 

949 

950 assert _get_histogram_labels_value(method, "True", "", "PERMISSION_DENIED") == hist_before + 1 

951 

952 

953def test_rejected_call_logged_service_missing_from_pool(db): 

954 """A servicer registered for a service missing from the descriptor pool is our bug, so it keeps its name.""" 

955 method = "/org.couchers.nonexistent.NA/GetNothing" 

956 hist_before = _get_histogram_labels_value(method, "False", "", "UNIMPLEMENTED") 

957 api_calls_before = _get_api_call_count(method, "unknown") 

958 bucketed_before = _get_histogram_labels_value(NONEXISTENT_METHOD_LABEL, "False", "", "UNIMPLEMENTED") 

959 setup_db_before = _get_histogram_count( 

960 servicer_setup_db_time_histogram, "couchers_servicer_setup_db_time_seconds_count", method=method 

961 ) 

962 

963 def TestRpc(request, context, session): 

964 return empty_pb2.Empty() 

965 

966 with interceptor_dummy_api( 

967 TestRpc, 

968 interceptors=[CouchersMiddlewareInterceptor()], 

969 service_name="org.couchers.nonexistent.NA", 

970 method_name="GetNothing", 

971 ) as call_rpc: 

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

973 call_rpc(empty_pb2.Empty(), metadata=(("x-couchers-real-ip", "1.1.1.1"),)) 

974 assert e.value.code() == grpc.StatusCode.UNIMPLEMENTED 

975 assert e.value.details() == NONEXISTENT_API_CALL_ERROR_MESSAGE 

976 

977 with session_scope() as session: 

978 trace = session.execute(select(APICall)).scalar_one() 

979 assert trace.method == method 

980 assert trace.status_code == "UNIMPLEMENTED" 

981 assert trace.user_id is None 

982 # headers are parsed before the auth level is looked up, so we know who made the call 

983 assert trace.ip_address == "1.1.1.1" 

984 assert trace.user_agent is not None 

985 

986 # the method is one this server registered, so it's a label of its own rather than bucketed 

987 assert _get_histogram_labels_value(method, "False", "", "UNIMPLEMENTED") == hist_before + 1 

988 assert _get_api_call_count(method, "unknown") == api_calls_before + 1 

989 assert ( 

990 _get_histogram_count( 

991 servicer_setup_db_time_histogram, "couchers_servicer_setup_db_time_seconds_count", method=method 

992 ) 

993 == setup_db_before + 1 

994 ) 

995 assert _get_histogram_labels_value(NONEXISTENT_METHOD_LABEL, "False", "", "UNIMPLEMENTED") == bucketed_before 

996 

997 

998def test_rejected_call_logged_unregistered_method(db): 

999 """A method with no servicer registered is terminated by the interceptor itself, and still logged.""" 

1000 hist_before = _get_histogram_labels_value(NONEXISTENT_METHOD_LABEL, "False", "", "UNIMPLEMENTED") 

1001 

1002 def TestRpc(request, context, session): 

1003 return empty_pb2.Empty() 

1004 

1005 with interceptor_dummy_api( 

1006 TestRpc, 

1007 interceptors=[CouchersMiddlewareInterceptor()], 

1008 call_method_name="NotRegistered", 

1009 ) as call_rpc: 

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

1011 call_rpc(empty_pb2.Empty(), metadata=(("x-couchers-real-ip", "1.1.1.1"),)) 

1012 assert e.value.code() == grpc.StatusCode.UNIMPLEMENTED 

1013 assert e.value.details() == NONEXISTENT_API_CALL_ERROR_MESSAGE 

1014 

1015 with session_scope() as session: 

1016 trace = session.execute(select(APICall)).scalar_one() 

1017 assert trace.method == "/org.couchers.auth.Auth/NotRegistered" 

1018 assert trace.status_code == "UNIMPLEMENTED" 

1019 assert trace.user_id is None 

1020 assert trace.ip_address == "1.1.1.1" 

1021 # no servicer was found, so the request was never deserialized 

1022 assert trace.request is None 

1023 

1024 assert _get_histogram_labels_value(NONEXISTENT_METHOD_LABEL, "False", "", "UNIMPLEMENTED") == hist_before + 1 

1025 assert _get_histogram_labels_value("/org.couchers.auth.Auth/NotRegistered", "False", "", "UNIMPLEMENTED") == 0 

1026 

1027 

1028def test_rejected_call_logged_missing_auth_level(db): 

1029 def TestRpc(request, context, session): 

1030 return empty_pb2.Empty() 

1031 

1032 with interceptor_dummy_api( 

1033 TestRpc, 

1034 interceptors=[CouchersMiddlewareInterceptor()], 

1035 service_name="org.couchers.media.Media", 

1036 method_name="UploadConfirmation", 

1037 ) as call_rpc: 

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

1039 call_rpc(empty_pb2.Empty()) 

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

1041 assert e.value.details() == MISSING_AUTH_LEVEL_ERROR_MESSAGE 

1042 

1043 with session_scope() as session: 

1044 trace = session.execute(select(APICall)).scalar_one() 

1045 assert trace.method == "/org.couchers.media.Media/UploadConfirmation" 

1046 assert trace.status_code == "INTERNAL" 

1047 

1048 

1049def test_rejected_call_logged_bad_headers(db): 

1050 _, token = generate_user() 

1051 

1052 def TestRpc(request, context, session): 

1053 return empty_pb2.Empty() 

1054 

1055 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

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

1057 call_rpc(empty_pb2.Empty(), metadata=(cookie_auth(token), api_auth(token))) 

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

1059 assert e.value.details() == COOKIES_AND_AUTH_HEADER_ERROR_MESSAGE 

1060 

1061 with session_scope() as session: 

1062 trace = session.execute(select(APICall)).scalar_one() 

1063 assert trace.method == "/org.couchers.auth.Auth/SignupFlow" 

1064 assert trace.status_code == "UNAUTHENTICATED" 

1065 assert trace.user_id is None 

1066 # the headers couldn't be parsed, so nothing is known about the client 

1067 assert trace.ip_address is None 

1068 assert trace.user_agent is None 

1069 

1070 

1071def test_parse_headers_with_session_cookie(): 

1072 headers = {"cookie": "couchers-sesh=abc123; other-cookie=value"} 

1073 result = parse_headers(headers) 

1074 assert result.token == "abc123" 

1075 assert result.is_api_key is False 

1076 

1077 

1078def test_parse_headers_with_authorization_header(): 

1079 headers = {"authorization": "Bearer abc123"} 

1080 result = parse_headers(headers) 

1081 assert result.token == "abc123" 

1082 assert result.is_api_key is True 

1083 

1084 

1085def test_parse_headers_with_both_cookie_and_authorization(): 

1086 headers = {"cookie": "couchers-sesh=abc123", "authorization": "Bearer xyz789"} 

1087 with pytest.raises(BadHeaders, match="Both cookies and authorization are present in headers"): 

1088 parse_headers(headers) 

1089 

1090 

1091def test_parse_headers_with_neither_cookie_nor_authorization(): 

1092 result = parse_headers({}) 

1093 assert result.token is None 

1094 assert result.is_api_key is False 

1095 

1096 

1097def test_parse_headers_with_all_optional_headers(): 

1098 headers = { 

1099 "cookie": "couchers-sesh=abc123; couchers-user-id=42; NEXT_LOCALE=en", 

1100 "x-couchers-real-ip": "192.168.1.1", 

1101 "user-agent": "TestAgent/1.0", 

1102 } 

1103 result = parse_headers(headers) 

1104 assert result.token == "abc123" 

1105 assert result.is_api_key is False 

1106 assert result.ip_address == "192.168.1.1" 

1107 assert result.user_agent == "TestAgent/1.0" 

1108 assert result.ui_lang == "en" 

1109 assert result.user_id_str == "42" 

1110 

1111 

1112def test_parse_headers_with_bytes_ip_address(): 

1113 headers: dict[str, str | bytes] = { 

1114 "cookie": "couchers-sesh=abc123", 

1115 "x-couchers-real-ip": b"192.168.1.1", 

1116 } 

1117 result = parse_headers(headers) 

1118 assert result.ip_address is None 

1119 

1120 

1121def test_parse_headers_with_bytes_user_agent(): 

1122 headers: dict[str, str | bytes] = { 

1123 "cookie": "couchers-sesh=abc123", 

1124 "user-agent": b"TestAgent/1.0", 

1125 } 

1126 result = parse_headers(headers) 

1127 assert result.user_agent is None 

1128 

1129 

1130def test_parse_headers_malformed_authorization(): 

1131 headers = {"authorization": "bearer abc123"} 

1132 result = parse_headers(headers) 

1133 assert result.token is None 

1134 assert result.is_api_key is True 

1135 

1136 

1137def test_auth_level_with_valid_service(): 

1138 result = get_proto_annotations().auth_level("/org.couchers.api.core.API/GetUser") 

1139 assert result == annotations_pb2.AUTH_LEVEL_SECURE 

1140 

1141 

1142def test_auth_level_with_nonexistent_service(): 

1143 with pytest.raises(CallRejectedError) as exc: 

1144 get_proto_annotations().auth_level("/org.couchers.nonexistent.Service/Method") 

1145 assert exc.value.msg == NONEXISTENT_API_CALL_ERROR_MESSAGE 

1146 assert exc.value.code == grpc.StatusCode.UNIMPLEMENTED 

1147 

1148 

1149def test_auth_level_with_unknown_auth_level(): 

1150 pool = Mock(spec=DescriptorPool) 

1151 service_desc = Mock(spec=ServiceDescriptor) 

1152 service_options = Mock() 

1153 service_options.Extensions = {annotations_pb2.auth_level: annotations_pb2.AUTH_LEVEL_UNKNOWN} 

1154 service_desc.GetOptions.return_value = service_options 

1155 pool.FindServiceByName.return_value = service_desc 

1156 

1157 with pytest.raises(CallRejectedError) as exc: 

1158 ProtoAnnotations(pool).auth_level("/org.couchers.api.core.API/GetUser") 

1159 assert exc.value.msg == MISSING_AUTH_LEVEL_ERROR_MESSAGE 

1160 assert exc.value.code == grpc.StatusCode.INTERNAL 

1161 

1162 

1163def test_validate_auth_level_with_unknown(): 

1164 with pytest.raises(CallRejectedError) as exc: 

1165 validate_auth_level(annotations_pb2.AUTH_LEVEL_UNKNOWN) 

1166 assert exc.value.msg == MISSING_AUTH_LEVEL_ERROR_MESSAGE 

1167 assert exc.value.code == grpc.StatusCode.INTERNAL 

1168 

1169 

1170def test_validate_auth_level_with_open(): 

1171 validate_auth_level(annotations_pb2.AUTH_LEVEL_OPEN) 

1172 

1173 

1174def test_validate_auth_level_with_jailed(): 

1175 validate_auth_level(annotations_pb2.AUTH_LEVEL_JAILED) 

1176 

1177 

1178def test_validate_auth_level_with_secure(): 

1179 validate_auth_level(annotations_pb2.AUTH_LEVEL_SECURE) 

1180 

1181 

1182def test_validate_auth_level_with_editor(): 

1183 validate_auth_level(annotations_pb2.AUTH_LEVEL_EDITOR) 

1184 

1185 

1186def test_validate_auth_level_with_admin(): 

1187 validate_auth_level(annotations_pb2.AUTH_LEVEL_ADMIN) 

1188 

1189 

1190def test_check_auth_open_service_without_auth(): 

1191 check_permissions(None, annotations_pb2.AUTH_LEVEL_OPEN) 

1192 

1193 

1194def test_check_auth_open_service_with_auth(): 

1195 auth_info = UserAuthInfo( 

1196 user_id=1, 

1197 is_jailed=False, 

1198 is_editor=False, 

1199 is_superuser=False, 

1200 token_expiry=now(), 

1201 ui_language_preference="en", 

1202 timezone="Etc/UTC", 

1203 token="abc123", 

1204 is_api_key=False, 

1205 ) 

1206 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_OPEN) 

1207 

1208 

1209def test_check_auth_secure_service_without_auth(): 

1210 with pytest.raises(CallRejectedError): 

1211 check_permissions(None, annotations_pb2.AUTH_LEVEL_SECURE) 

1212 

1213 

1214def test_check_auth_secure_service_with_normal_auth(): 

1215 auth_info = UserAuthInfo( 

1216 user_id=1, 

1217 is_jailed=False, 

1218 is_editor=False, 

1219 is_superuser=False, 

1220 token_expiry=now(), 

1221 ui_language_preference="en", 

1222 timezone="Etc/UTC", 

1223 token="abc123", 

1224 is_api_key=False, 

1225 ) 

1226 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_SECURE) 

1227 

1228 

1229def test_check_auth_secure_service_with_jailed_user(): 

1230 auth_info = UserAuthInfo( 

1231 user_id=1, 

1232 is_jailed=True, 

1233 is_editor=False, 

1234 is_superuser=False, 

1235 token_expiry=now(), 

1236 ui_language_preference="en", 

1237 timezone="Etc/UTC", 

1238 token="abc123", 

1239 is_api_key=False, 

1240 ) 

1241 with pytest.raises(CallRejectedError): 

1242 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_SECURE) 

1243 

1244 

1245def test_check_auth_jailed_service_with_jailed_user(): 

1246 auth_info = UserAuthInfo( 

1247 user_id=1, 

1248 is_jailed=True, 

1249 is_editor=False, 

1250 is_superuser=False, 

1251 token_expiry=now(), 

1252 ui_language_preference="en", 

1253 timezone="Etc/UTC", 

1254 token="abc123", 

1255 is_api_key=False, 

1256 ) 

1257 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_JAILED) 

1258 

1259 

1260def test_check_auth_jailed_service_without_auth(): 

1261 with pytest.raises(CallRejectedError): 

1262 check_permissions(None, annotations_pb2.AUTH_LEVEL_JAILED) 

1263 

1264 

1265def test_check_auth_editor_service_without_editor(): 

1266 auth_info = UserAuthInfo( 

1267 user_id=1, 

1268 is_jailed=False, 

1269 is_editor=False, 

1270 is_superuser=False, 

1271 token_expiry=now(), 

1272 ui_language_preference="en", 

1273 timezone="Etc/UTC", 

1274 token="abc123", 

1275 is_api_key=False, 

1276 ) 

1277 with pytest.raises(CallRejectedError): 

1278 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_EDITOR) 

1279 

1280 

1281def test_check_auth_editor_service_with_editor(): 

1282 auth_info = UserAuthInfo( 

1283 user_id=1, 

1284 is_jailed=False, 

1285 is_editor=True, 

1286 is_superuser=False, 

1287 token_expiry=now(), 

1288 ui_language_preference="en", 

1289 timezone="Etc/UTC", 

1290 token="abc123", 

1291 is_api_key=False, 

1292 ) 

1293 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_EDITOR) 

1294 

1295 

1296def test_check_auth_admin_service_without_superuser(): 

1297 auth_info = UserAuthInfo( 

1298 user_id=1, 

1299 is_jailed=False, 

1300 is_editor=True, 

1301 is_superuser=False, 

1302 token_expiry=now(), 

1303 ui_language_preference="en", 

1304 timezone="Etc/UTC", 

1305 token="abc123", 

1306 is_api_key=False, 

1307 ) 

1308 with pytest.raises(CallRejectedError): 

1309 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_ADMIN) 

1310 

1311 

1312def test_check_auth_admin_service_with_superuser(): 

1313 auth_info = UserAuthInfo( 

1314 user_id=1, 

1315 is_jailed=False, 

1316 is_editor=True, 

1317 is_superuser=True, 

1318 token_expiry=now(), 

1319 ui_language_preference="en", 

1320 timezone="Etc/UTC", 

1321 token="abc123", 

1322 is_api_key=False, 

1323 ) 

1324 check_permissions(auth_info, annotations_pb2.AUTH_LEVEL_ADMIN) 

1325 

1326 

1327def test_check_auth_admin_service_without_auth(): 

1328 with pytest.raises(CallRejectedError): 

1329 check_permissions(None, annotations_pb2.AUTH_LEVEL_ADMIN) 

1330 

1331 

1332def test_parse_sofa_cookie_valid(): 

1333 sofa_value, cookie_string = generate_sofa_cookie() 

1334 cookie_value = cookie_string.split("=", 1)[1].split(";")[0] 

1335 

1336 headers = {"cookie": f"sofa={cookie_value}"} 

1337 result = parse_sofa_cookie(headers) 

1338 assert result == sofa_value 

1339 

1340 

1341def test_parse_sofa_cookie_missing(): 

1342 headers = {"cookie": "other-cookie=value"} 

1343 result = parse_sofa_cookie(headers) 

1344 assert result is None 

1345 

1346 

1347def test_parse_sofa_cookie_no_cookies(): 

1348 headers: dict[str, str] = {} 

1349 result = parse_sofa_cookie(headers) 

1350 assert result is None 

1351 

1352 

1353def test_parse_sofa_cookie_invalid_base64(): 

1354 headers = {"cookie": "sofa=not-valid-base64!!!"} 

1355 result = parse_sofa_cookie(headers) 

1356 assert result is None 

1357 

1358 

1359def test_parse_sofa_cookie_invalid_encryption(): 

1360 headers = {"cookie": f"sofa={b64encode(b'invalid encrypted data')}"} 

1361 result = parse_sofa_cookie(headers) 

1362 assert result is None 

1363 

1364 

1365def test_parse_sofa_cookie_invalid_proto(): 

1366 encrypted = simple_encrypt("sofa_cookie", b"not a valid proto") 

1367 headers = {"cookie": f"sofa={b64encode(encrypted)}"} 

1368 result = parse_sofa_cookie(headers) 

1369 assert result is not None or result is None 

1370 

1371 

1372def test_generate_sofa_cookie(): 

1373 sofa_value, cookie_string = generate_sofa_cookie() 

1374 

1375 assert sofa_value 

1376 assert isinstance(sofa_value, str) 

1377 assert len(sofa_value) > 20 

1378 

1379 assert "sofa=" in cookie_string 

1380 assert "expires=" in cookie_string.lower() 

1381 

1382 cookie_value = cookie_string.split("=", 1)[1].split(";")[0] 

1383 headers = {"cookie": f"sofa={cookie_value}"} 

1384 parsed_value = parse_sofa_cookie(headers) 

1385 assert parsed_value == sofa_value 

1386 

1387 

1388def test_parse_headers_with_sofa_cookie(): 

1389 sofa_value, cookie_string = generate_sofa_cookie() 

1390 cookie_value = cookie_string.split("=", 1)[1].split(";")[0] 

1391 

1392 headers = { 

1393 "cookie": f"couchers-sesh=abc123; sofa={cookie_value}", 

1394 } 

1395 result = parse_headers(headers) 

1396 assert result.token == "abc123" 

1397 assert result.sofa == sofa_value 

1398 

1399 

1400def test_parse_headers_without_sofa_cookie(): 

1401 headers = { 

1402 "cookie": "couchers-sesh=abc123", 

1403 } 

1404 result = parse_headers(headers) 

1405 assert result.token == "abc123" 

1406 assert result.sofa is None 

1407 

1408 

1409def test_sofa_cookie_logged_new(db): 

1410 def TestRpc(request, context, session): 

1411 return empty_pb2.Empty() 

1412 

1413 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

1414 call_rpc(empty_pb2.Empty()) 

1415 

1416 with session_scope() as session: 

1417 trace = session.execute(select(APICall)).scalar_one() 

1418 assert trace.sofa is not None 

1419 assert len(trace.sofa) > 20 

1420 

1421 

1422def test_sofa_cookie_logged_existing(db): 

1423 sofa_value, cookie_string = generate_sofa_cookie() 

1424 cookie_value = cookie_string.split("=", 1)[1].split(";")[0] 

1425 

1426 def TestRpc(request, context, session): 

1427 return empty_pb2.Empty() 

1428 

1429 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

1430 call_rpc(empty_pb2.Empty(), metadata=(("cookie", f"sofa={cookie_value}"),)) 

1431 

1432 with session_scope() as session: 

1433 trace = session.execute(select(APICall)).scalar_one() 

1434 assert trace.sofa == sofa_value 

1435 

1436 

1437def test_sofa_cookie_logged_invalid_generates_new(db): 

1438 def TestRpc(request, context, session): 

1439 return empty_pb2.Empty() 

1440 

1441 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

1442 call_rpc(empty_pb2.Empty(), metadata=(("cookie", "sofa=invalid-cookie-value"),)) 

1443 

1444 with session_scope() as session: 

1445 trace = session.execute(select(APICall)).scalar_one() 

1446 assert trace.sofa is not None 

1447 assert trace.sofa != "invalid-cookie-value" 

1448 assert len(trace.sofa) > 20 

1449 

1450 

1451def test_sofa_cookie_with_authenticated_user(db): 

1452 user, token = generate_user() 

1453 sofa_value, cookie_string = generate_sofa_cookie() 

1454 cookie_value = cookie_string.split("=", 1)[1].split(";")[0] 

1455 

1456 account = Account() 

1457 

1458 rpc_def = { 

1459 "rpc": account.GetAccountInfo, 

1460 "service_name": "org.couchers.api.account.Account", 

1461 "method_name": "GetAccountInfo", 

1462 "interceptors": [CouchersMiddlewareInterceptor()], 

1463 "request_type": empty_pb2.Empty, 

1464 "response_type": account_pb2.GetAccountInfoRes, 

1465 } 

1466 

1467 with interceptor_dummy_api(**rpc_def, creds=grpc.local_channel_credentials()) as call_rpc: 

1468 res = call_rpc(empty_pb2.Empty(), metadata=(("cookie", f"couchers-sesh={token}; sofa={cookie_value}"),)) 

1469 assert res.username == user.username 

1470 

1471 with session_scope() as session: 

1472 trace = session.execute(select(APICall)).scalar_one() 

1473 assert trace.user_id == user.id 

1474 assert trace.sofa == sofa_value 

1475 

1476 

1477def test_sofa_cookie_persists_on_exception(db): 

1478 sofa_value, cookie_string = generate_sofa_cookie() 

1479 cookie_value = cookie_string.split("=", 1)[1].split(";")[0] 

1480 

1481 def TestRpc(request, context, session): 

1482 raise Exception("Test error") 

1483 

1484 with interceptor_dummy_api(TestRpc, interceptors=[CouchersMiddlewareInterceptor()]) as call_rpc: 

1485 with pytest.raises(Exception, match="Test error"): 

1486 call_rpc(empty_pb2.Empty(), metadata=(("cookie", f"sofa={cookie_value}"),)) 

1487 

1488 with session_scope() as session: 

1489 trace = session.execute(select(APICall)).scalar_one() 

1490 assert trace.sofa == sofa_value 

1491 assert trace.traceback is not None 

1492 assert "Test error" in trace.traceback