Coverage for app/backend/src/tests/test_bugs.py: 97%

379 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 12:25 +0000

1import json 

2import uuid 

3from datetime import UTC, datetime, timedelta 

4from unittest.mock import patch 

5 

6import grpc 

7import pytest 

8from google.protobuf import empty_pb2, timestamp_pb2 

9from sqlalchemy import func, select 

10 

11from couchers.config import config 

12from couchers.crypto import random_hex 

13from couchers.db import session_scope 

14from couchers.models import NativeClientUser, OTAPackage, OTAPlatform 

15from couchers.models.logging import EventLog, EventSource, ExperimentExposure, ExposureSource 

16from couchers.proto import bugs_pb2 

17from couchers.proto.google.api import httpbody_pb2 

18from couchers.servicers.bugs import _fetch_signed_manifest 

19from tests.fixtures.db import generate_user 

20from tests.fixtures.sessions import bugs_session, real_bugs_session 

21 

22EAS_CLIENT_ID = uuid.UUID("11111111-1111-1111-1111-111111111111") 

23 

24 

25@pytest.fixture(autouse=True) 

26def _(testconfig): 

27 pass 

28 

29 

30def test_bugs_disabled(): 

31 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e: 

32 bugs.ReportBug( 

33 bugs_pb2.ReportBugReq( 

34 subject="subject", 

35 description="description", 

36 results="results", 

37 frontend_version="frontend_version", 

38 user_agent="user_agent", 

39 page="page", 

40 ) 

41 ) 

42 assert e.value.code() == grpc.StatusCode.UNAVAILABLE 

43 

44 

45def test_bugs(db): 

46 with bugs_session() as bugs: 

47 

48 def dud_post(url, auth, json): 

49 assert url == "https://api.github.com/repos/org/repo/issues" 

50 assert auth == ("user", "token") 

51 

52 expected_body = f""" 

53# subject 

54## Description 

55description 

56 

57## Results 

58results 

59 

60## Diagnostics 

61**Backend version**: `{config.VERSION}` 

62**Frontend version**: `frontend_version` 

63**User Agent**: `user_agent` 

64**Locale**: `en` 

65**Screen resolution**: 1920x1080 

66**Page**: page 

67**User**: <not logged in> / `test_sofa_co`""".strip() 

68 

69 assert json == { 

70 "title": "subject", 

71 "body": expected_body, 

72 "labels": ["bug: triage needed"], 

73 } 

74 

75 class _PostReturn: 

76 status_code = 201 

77 

78 def json(self): 

79 return {"number": 11} 

80 

81 return _PostReturn() 

82 

83 new_config = config.copy() 

84 new_config.BUG_TOOL_ENABLED = True 

85 

86 with patch("couchers.servicers.bugs.config", new_config): 

87 with patch("couchers.servicers.bugs.requests.post", dud_post): 87 ↛ anywhereline 87 didn't jump anywhere: it always raised an exception.

88 res = bugs.ReportBug( 

89 bugs_pb2.ReportBugReq( 

90 subject="subject", 

91 description="description", 

92 results="results", 

93 frontend_version="frontend_version", 

94 user_agent="user_agent", 

95 screen_resolution=bugs_pb2.ScreenResolution(width=1920, height=1080), 

96 page="page", 

97 ) 

98 ) 

99 

100 assert res.bug_id == "#11" 

101 assert res.bug_url == "https://github.com/org/repo/issues/11" 

102 

103 

104def test_bugs_with_user(db, frozen_timewarp): 

105 user, token = generate_user(username="testing_user") 

106 

107 with bugs_session(token) as bugs: 

108 

109 def dud_post(url, auth, json): 

110 assert url == "https://api.github.com/repos/org/repo/issues" 

111 assert auth == ("user", "token") 

112 

113 expected_body = f""" 

114# subject 

115## Description 

116description 

117 

118## Results 

119results 

120 

121## Diagnostics 

122**Backend version**: `{config.VERSION}` 

123**Frontend version**: `frontend_version` 

124**User Agent**: `user_agent` 

125**Locale**: `en` 

126**Screen resolution**: 390x844 

127**Page**: page 

128**User**: [@testing_user](http://localhost:3000/user/testing_user) (1) / `test_sofa_co` 

129**Sentry (this user)**: https://couchers.sentry.io/issues/?project=1234&query=user.id%3A1&start=2019-12-31T00%3A00%3A00&end=2020-01-01T01%3A00%3A00&utc=true""".strip() 

130 

131 assert json == { 

132 "title": "subject", 

133 "body": expected_body, 

134 "labels": ["bug: triage needed"], 

135 } 

136 

137 class _PostReturn: 

138 status_code = 201 

139 

140 def json(self): 

141 return {"number": 11} 

142 

143 return _PostReturn() 

144 

145 new_config = config.copy() 

146 new_config.BUG_TOOL_ENABLED = True 

147 

148 with patch("couchers.servicers.bugs.config", new_config): 

149 with patch("couchers.servicers.bugs.requests.post", dud_post): 149 ↛ anywhereline 149 didn't jump anywhere: it always raised an exception.

150 res = bugs.ReportBug( 

151 bugs_pb2.ReportBugReq( 

152 subject="subject", 

153 description="description", 

154 results="results", 

155 frontend_version="frontend_version", 

156 user_agent="user_agent", 

157 screen_resolution=bugs_pb2.ScreenResolution(width=390, height=844), 

158 page="page", 

159 ) 

160 ) 

161 

162 assert res.bug_id == "#11" 

163 assert res.bug_url == "https://github.com/org/repo/issues/11" 

164 

165 

166def test_bugs_with_sentry_replay(db): 

167 with bugs_session() as bugs: 

168 

169 def dud_post(url, auth, json): 

170 expected_body = f""" 

171# subject 

172## Description 

173description 

174 

175## Results 

176results 

177 

178## Diagnostics 

179**Backend version**: `{config.VERSION}` 

180**Frontend version**: `frontend_version` 

181**User Agent**: `user_agent` 

182**Locale**: `en` 

183**Screen resolution**: 1920x1080 

184**Page**: page 

185**User**: <not logged in> / `test_sofa_co` 

186**Session replay**: https://couchers.sentry.io/replays/0123456789abcdef0123456789abcdef/?project=1234""".strip() 

187 

188 assert json["body"] == expected_body 

189 

190 class _PostReturn: 

191 status_code = 201 

192 

193 def json(self): 

194 return {"number": 11} 

195 

196 return _PostReturn() 

197 

198 new_config = config.copy() 

199 new_config.BUG_TOOL_ENABLED = True 

200 

201 with patch("couchers.servicers.bugs.config", new_config): 

202 with patch("couchers.servicers.bugs.requests.post", dud_post): 202 ↛ anywhereline 202 didn't jump anywhere: it always raised an exception.

203 bugs.ReportBug( 

204 bugs_pb2.ReportBugReq( 

205 subject="subject", 

206 description="description", 

207 results="results", 

208 frontend_version="frontend_version", 

209 user_agent="user_agent", 

210 screen_resolution=bugs_pb2.ScreenResolution(width=1920, height=1080), 

211 page="page", 

212 sentry_replay_id="0123456789abcdef0123456789abcdef", 

213 ) 

214 ) 

215 

216 

217def test_bugs_invalid_sentry_replay_id_omitted(db): 

218 # A malformed/garbage replay id must not be interpolated into the issue at all. 

219 with bugs_session() as bugs: 

220 

221 def dud_post(url, auth, json): 

222 assert "Session replay" not in json["body"] 

223 

224 class _PostReturn: 

225 status_code = 201 

226 

227 def json(self): 

228 return {"number": 11} 

229 

230 return _PostReturn() 

231 

232 new_config = config.copy() 

233 new_config.BUG_TOOL_ENABLED = True 

234 

235 with patch("couchers.servicers.bugs.config", new_config): 

236 with patch("couchers.servicers.bugs.requests.post", dud_post): 236 ↛ anywhereline 236 didn't jump anywhere: it always raised an exception.

237 bugs.ReportBug( 

238 bugs_pb2.ReportBugReq( 

239 subject="subject", 

240 description="description", 

241 results="results", 

242 sentry_replay_id="not-a-replay-id](https://evil.example)", 

243 ) 

244 ) 

245 

246 

247def test_bugs_fails_on_network_error(db): 

248 with bugs_session() as bugs: 

249 

250 def dud_post(url, auth, json): 

251 class _PostReturn: 

252 status_code = 400 

253 

254 return _PostReturn() 

255 

256 new_config = config.copy() 

257 new_config.BUG_TOOL_ENABLED = True 

258 

259 with patch("couchers.servicers.bugs.config", new_config): 

260 with patch("couchers.servicers.bugs.requests.post", dud_post): 260 ↛ anywhereline 260 didn't jump anywhere: it always raised an exception.

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

262 res = bugs.ReportBug( 

263 bugs_pb2.ReportBugReq( 

264 subject="subject", 

265 description="description", 

266 results="results", 

267 frontend_version="frontend_version", 

268 user_agent="user_agent", 

269 page="page", 

270 ) 

271 ) 

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

273 

274 

275def test_version(): 

276 with bugs_session() as bugs: 

277 res = bugs.Version(empty_pb2.Empty()) 

278 assert res.version == "testing_version" 

279 

280 

281def test_status(db): 

282 for _ in range(5): 

283 generate_user() 

284 

285 with bugs_session() as bugs: 

286 nonce = random_hex() 

287 res = bugs.Status(bugs_pb2.StatusReq(nonce=nonce)) 

288 assert res.nonce == nonce 

289 assert res.version == "testing_version" 

290 assert res.coucher_count == 5 

291 

292 

293def test_GetDescriptors(): 

294 with bugs_session() as bugs: 

295 res = bugs.GetDescriptors(empty_pb2.Empty()) 

296 # test we got something roughly binary back 

297 assert res.content_type == "application/octet-stream" 

298 assert len(res.data) > 2**12 

299 

300 

301def _get_events(session, event_type=None): 

302 stmt = select(EventLog).order_by(EventLog.id) 

303 if event_type: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 stmt = stmt.where(EventLog.event_type == event_type) 

305 return session.execute(stmt).scalars().all() 

306 

307 

308def test_report_diagnostics_anonymous(db): 

309 with bugs_session() as bugs: 

310 bugs.ReportDiagnostics( 

311 bugs_pb2.ReportDiagnosticsReq( 

312 frontend_version="1.2.3", 

313 infos=[ 

314 bugs_pb2.DiagnosticInfo( 

315 tag="page.viewed", 

316 properties_json='{"path": "/"}', 

317 value=1, 

318 ), 

319 bugs_pb2.DiagnosticInfo( 

320 tag="session.started", 

321 properties_json='{"referrer": "google.com"}', 

322 value=1, 

323 ), 

324 ], 

325 ) 

326 ) 

327 

328 with session_scope() as session: 

329 events = _get_events(session) 

330 assert len(events) == 2 

331 

332 e0 = events[0] 

333 assert e0.event_type == "page.viewed" 

334 assert e0.properties == {"path": "/"} 

335 assert e0.user_id is None 

336 assert e0.source == EventSource.frontend 

337 assert e0.value == 1 

338 assert e0.version == "1.2.3" 

339 

340 e1 = events[1] 

341 assert e1.event_type == "session.started" 

342 assert e1.properties == {"referrer": "google.com"} 

343 assert e1.source == EventSource.frontend 

344 

345 

346def test_report_diagnostics_authenticated(db): 

347 user, token = generate_user() 

348 

349 with bugs_session(token) as bugs: 

350 bugs.ReportDiagnostics( 

351 bugs_pb2.ReportDiagnosticsReq( 

352 frontend_version="1.2.3", 

353 infos=[ 

354 bugs_pb2.DiagnosticInfo( 

355 tag="page.viewed", 

356 properties_json='{"path": "/search"}', 

357 value=1, 

358 ), 

359 ], 

360 ) 

361 ) 

362 

363 with session_scope() as session: 

364 events = _get_events(session) 

365 assert len(events) == 1 

366 assert events[0].user_id == user.id 

367 assert events[0].source == EventSource.frontend 

368 

369 

370def test_report_diagnostics_with_value(db): 

371 with bugs_session() as bugs: 

372 bugs.ReportDiagnostics( 

373 bugs_pb2.ReportDiagnosticsReq( 

374 frontend_version="1.2.3", 

375 infos=[ 

376 bugs_pb2.DiagnosticInfo( 

377 tag="search.result_hovered", 

378 properties_json='{"user_id": 5}', 

379 value=1500.5, 

380 ), 

381 ], 

382 ) 

383 ) 

384 

385 with session_scope() as session: 

386 events = _get_events(session) 

387 assert len(events) == 1 

388 assert events[0].value == pytest.approx(1500.5) 

389 

390 

391def test_report_diagnostics_with_occurred(db): 

392 ts = timestamp_pb2.Timestamp() 

393 ts.FromDatetime(datetime(2026, 1, 15, 10, 30, 0, tzinfo=UTC)) 

394 

395 with bugs_session() as bugs: 

396 bugs.ReportDiagnostics( 

397 bugs_pb2.ReportDiagnosticsReq( 

398 frontend_version="1.2.3", 

399 infos=[ 

400 bugs_pb2.DiagnosticInfo( 

401 tag="page.viewed", 

402 properties_json="{}", 

403 value=1, 

404 occurred=ts, 

405 ), 

406 ], 

407 ) 

408 ) 

409 

410 with session_scope() as session: 

411 events = _get_events(session) 

412 assert len(events) == 1 

413 assert events[0].occurred.year == 2026 

414 assert events[0].occurred.month == 1 

415 assert events[0].occurred.day == 15 

416 assert events[0].occurred.hour == 10 

417 assert events[0].occurred.minute == 30 

418 

419 

420def test_report_diagnostics_invalid_json(db): 

421 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e: 

422 bugs.ReportDiagnostics( 

423 bugs_pb2.ReportDiagnosticsReq( 

424 frontend_version="1.2.3", 

425 infos=[ 

426 bugs_pb2.DiagnosticInfo( 

427 tag="page.viewed", 

428 properties_json="not valid json{{{", 

429 value=1, 

430 ), 

431 ], 

432 ) 

433 ) 

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

435 

436 

437def test_report_diagnostics_empty_batch(db): 

438 with bugs_session() as bugs: 

439 bugs.ReportDiagnostics( 

440 bugs_pb2.ReportDiagnosticsReq( 

441 frontend_version="1.2.3", 

442 infos=[], 

443 ) 

444 ) 

445 

446 with session_scope() as session: 

447 events = _get_events(session) 

448 assert len(events) == 0 

449 

450 

451def test_report_diagnostics_too_many(db): 

452 infos = [bugs_pb2.DiagnosticInfo(tag=f"event.{i}", properties_json="{}", value=1) for i in range(101)] 

453 

454 with bugs_session() as bugs, pytest.raises(grpc.RpcError) as e: 

455 bugs.ReportDiagnostics( 

456 bugs_pb2.ReportDiagnosticsReq( 

457 frontend_version="1.2.3", 

458 infos=infos, 

459 ) 

460 ) 

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

462 

463 

464def test_report_diagnostics_frontend_version(db): 

465 with bugs_session() as bugs: 

466 bugs.ReportDiagnostics( 

467 bugs_pb2.ReportDiagnosticsReq( 

468 frontend_version="abc-def-123", 

469 infos=[ 

470 bugs_pb2.DiagnosticInfo( 

471 tag="page.viewed", 

472 properties_json="{}", 

473 value=1, 

474 ), 

475 ], 

476 ) 

477 ) 

478 

479 with session_scope() as session: 

480 events = _get_events(session) 

481 assert len(events) == 1 

482 assert events[0].version == "abc-def-123" 

483 

484 

485def test_check_native_status_anonymous(db): 

486 with bugs_session() as bugs: 

487 res = bugs.CheckNativeStatus( 

488 bugs_pb2.CheckNativeStatusReq( 

489 eas_client_id=str(EAS_CLIENT_ID), app_version="1.1.20", platform="ios", user_state="logged_out" 

490 ) 

491 ) 

492 

493 # No build timestamps reported -> no clock runs -> no update asked for. 

494 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE 

495 assert res.update_info.required is False 

496 

497 

498def test_check_native_status_authenticated(db): 

499 _, token = generate_user() 

500 

501 with bugs_session(token) as bugs: 

502 res = bugs.CheckNativeStatus( 

503 bugs_pb2.CheckNativeStatusReq( 

504 eas_client_id=str(EAS_CLIENT_ID), platform="android", user_state="authenticated" 

505 ) 

506 ) 

507 

508 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_NONE 

509 assert res.update_info.required is False 

510 

511 

512def test_check_native_status_authenticated_records_mapping(db): 

513 user, token = generate_user() 

514 

515 with bugs_session(token) as bugs: 

516 bugs.CheckNativeStatus( 

517 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="authenticated") 

518 ) 

519 

520 with session_scope() as session: 

521 row = session.execute( 

522 select(NativeClientUser).where(NativeClientUser.eas_client_id == EAS_CLIENT_ID) 

523 ).scalar_one() 

524 assert row.user_id == user.id 

525 

526 

527def test_check_native_status_anonymous_does_not_record_mapping(db): 

528 with bugs_session() as bugs: 

529 bugs.CheckNativeStatus( 

530 bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios", user_state="logged_out") 

531 ) 

532 

533 with session_scope() as session: 

534 count = session.execute(select(func.count()).select_from(NativeClientUser)).scalar_one() 

535 assert count == 0 

536 

537 

538def test_check_native_status_append_only_log_of_sightings(db): 

539 # A shared install — same eas-client-id, two users — produces two rows; newest is user_b. 

540 user_a, token_a = generate_user() 

541 user_b, token_b = generate_user() 

542 

543 with bugs_session(token_a) as bugs: 

544 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios")) 

545 with bugs_session(token_b) as bugs: 

546 bugs.CheckNativeStatus(bugs_pb2.CheckNativeStatusReq(eas_client_id=str(EAS_CLIENT_ID), platform="ios")) 

547 

548 with session_scope() as session: 

549 rows = ( 

550 session.execute( 

551 select(NativeClientUser) 

552 .where(NativeClientUser.eas_client_id == EAS_CLIENT_ID) 

553 .order_by(NativeClientUser.id) 

554 ) 

555 .scalars() 

556 .all() 

557 ) 

558 assert [r.user_id for r in rows] == [user_a.id, user_b.id] 

559 

560 

561def test_check_native_status_blocks_expired_binary(db): 

562 # A native binary older than the (default 91-day) store window -> required store update, blocking. 

563 embedded_created_at = timestamp_pb2.Timestamp() 

564 embedded_created_at.FromDatetime(datetime.now(UTC) - timedelta(days=120)) 

565 with bugs_session() as bugs: 

566 res = bugs.CheckNativeStatus( 

567 bugs_pb2.CheckNativeStatusReq( 

568 eas_client_id=str(EAS_CLIENT_ID), platform="ios", embedded_created_at=embedded_created_at 

569 ) 

570 ) 

571 

572 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_STORE 

573 assert res.update_info.required is True 

574 assert res.update_info.act_by.ToDatetime(tzinfo=UTC) <= datetime.now(UTC) 

575 

576 

577def _multipart_part_json(body, name): 

578 """Extract and parse the JSON body of a named part from a multipart/mixed body.""" 

579 marker = f'name="{name}"' 

580 start = body.index("\r\n\r\n", body.index(marker)) + 4 

581 end = body.index("\r\n--", start) 

582 return json.loads(body[start:end]) 

583 

584 

585_OTA_CDN_ROOT = "https://cdn.testing.invalid/native/ota" 

586_CDN_CONTENT_TYPE = "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY" 

587 

588 

589class _FakeCDNResponse: 

590 # Echoes the requested URL back as the body so tests can assert which version was fetched and that 

591 # the bytes are served verbatim — standing in for the pre-signed manifest the CDN holds. 

592 def __init__(self, url): 

593 self.headers = {"content-type": _CDN_CONTENT_TYPE} 

594 self.content = url.encode() 

595 

596 def raise_for_status(self): 

597 pass 

598 

599 

600def _patch_cdn(): 

601 return patch("couchers.servicers.bugs.requests.get", side_effect=lambda url, timeout=None: _FakeCDNResponse(url)) 

602 

603 

604def _add_ota_package(*, platform, fingerprint, version, created_at, banned=False): 

605 with session_scope() as session: 

606 creator, _ = generate_user() 

607 package = OTAPackage( 

608 creator_user_id=creator.id, 

609 platform=platform, 

610 fingerprint=fingerprint, 

611 version=version, 

612 manifest_created_at=created_at, 

613 manifest_id=f"id-{version}", 

614 banned_at=created_at if banned else None, 

615 banned_by_user_id=creator.id if banned else None, 

616 banned_reason="test ban" if banned else None, 

617 ) 

618 session.add(package) 

619 session.flush() 

620 

621 

622def test_native_update_manifest_serves_matching_package(db, feature_flags): 

623 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT) 

624 _fetch_signed_manifest.cache_clear() 

625 _add_ota_package( 

626 platform=OTAPlatform.ios, 

627 fingerprint="ios-fingerprint", 

628 version="v1.3.1.aaaa", 

629 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

630 ) 

631 with _patch_cdn(): 

632 with real_bugs_session() as (bugs, metadata_interceptor): 

633 res = bugs.GetNativeUpdateManifest( 

634 httpbody_pb2.HttpBody(), 

635 metadata=( 

636 ("eas-client-id", str(EAS_CLIENT_ID)), 

637 ("expo-platform", "ios"), 

638 ("expo-runtime-version", "ios-fingerprint"), 

639 ), 

640 ) 

641 

642 # the signed bytes are fetched from the CDN under the package's version and served verbatim 

643 assert res.content_type == _CDN_CONTENT_TYPE 

644 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.aaaa/ios/manifest" 

645 # the client requires these response headers or it rejects the manifest 

646 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1" 

647 assert metadata_interceptor.latest_headers["expo-sfv-version"] == "0" 

648 

649 

650def test_native_update_manifest_resolves_per_platform(db, feature_flags): 

651 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT) 

652 _fetch_signed_manifest.cache_clear() 

653 _add_ota_package( 

654 platform=OTAPlatform.ios, 

655 fingerprint="shared-fingerprint", 

656 version="v1.3.1.ios", 

657 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

658 ) 

659 _add_ota_package( 

660 platform=OTAPlatform.android, 

661 fingerprint="shared-fingerprint", 

662 version="v1.3.1.android", 

663 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

664 ) 

665 with _patch_cdn(): 

666 with real_bugs_session() as (bugs, _metadata_interceptor): 

667 res = bugs.GetNativeUpdateManifest( 

668 httpbody_pb2.HttpBody(), 

669 metadata=( 

670 ("eas-client-id", str(EAS_CLIENT_ID)), 

671 ("expo-platform", "android"), 

672 ("expo-runtime-version", "shared-fingerprint"), 

673 ), 

674 ) 

675 

676 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.android/android/manifest" 

677 

678 

679def test_native_update_manifest_serves_newest_by_created_at(db, feature_flags): 

680 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT) 

681 _fetch_signed_manifest.cache_clear() 

682 # the newer createdAt wins regardless of insertion order 

683 _add_ota_package( 

684 platform=OTAPlatform.ios, 

685 fingerprint="ios-fingerprint", 

686 version="v1.3.2.newer", 

687 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

688 ) 

689 _add_ota_package( 

690 platform=OTAPlatform.ios, 

691 fingerprint="ios-fingerprint", 

692 version="v1.3.1.older", 

693 created_at=datetime(2026, 5, 30, tzinfo=UTC), 

694 ) 

695 with _patch_cdn(): 

696 with real_bugs_session() as (bugs, _metadata_interceptor): 

697 res = bugs.GetNativeUpdateManifest( 

698 httpbody_pb2.HttpBody(), 

699 metadata=( 

700 ("eas-client-id", str(EAS_CLIENT_ID)), 

701 ("expo-platform", "ios"), 

702 ("expo-runtime-version", "ios-fingerprint"), 

703 ), 

704 ) 

705 

706 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.2.newer/ios/manifest" 

707 

708 

709def test_native_update_manifest_banned_package_excluded(db, feature_flags): 

710 feature_flags.set("native_ota_cdn_root", _OTA_CDN_ROOT) 

711 _fetch_signed_manifest.cache_clear() 

712 _add_ota_package( 

713 platform=OTAPlatform.ios, 

714 fingerprint="ios-fingerprint", 

715 version="v1.3.1.good", 

716 created_at=datetime(2026, 5, 30, tzinfo=UTC), 

717 ) 

718 _add_ota_package( 

719 platform=OTAPlatform.ios, 

720 fingerprint="ios-fingerprint", 

721 version="v1.3.2.bad", 

722 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

723 banned=True, 

724 ) 

725 with _patch_cdn(): 

726 with real_bugs_session() as (bugs, _metadata_interceptor): 

727 res = bugs.GetNativeUpdateManifest( 

728 httpbody_pb2.HttpBody(), 

729 metadata=( 

730 ("eas-client-id", str(EAS_CLIENT_ID)), 

731 ("expo-platform", "ios"), 

732 ("expo-runtime-version", "ios-fingerprint"), 

733 ), 

734 ) 

735 

736 # the newest is banned, so new check-ins get the previous one (a re-stamp would supersede it) 

737 assert res.data.decode() == f"{_OTA_CDN_ROOT}/v1.3.1.good/ios/manifest" 

738 

739 

740def test_native_update_manifest_runtime_mismatch_returns_directive(db): 

741 _add_ota_package( 

742 platform=OTAPlatform.ios, 

743 fingerprint="ios-fingerprint", 

744 version="v1.3.1.aaaa", 

745 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

746 ) 

747 with _patch_cdn() as cdn_get: 

748 with real_bugs_session() as (bugs, _metadata_interceptor): 

749 res = bugs.GetNativeUpdateManifest( 

750 httpbody_pb2.HttpBody(), 

751 metadata=( 

752 ("eas-client-id", str(EAS_CLIENT_ID)), 

753 ("expo-platform", "ios"), 

754 ("expo-runtime-version", "some-other-fingerprint"), 

755 ), 

756 ) 

757 

758 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"} 

759 # a mismatch must not even fetch — the manifest would be rejected on this build 

760 cdn_get.assert_not_called() 

761 

762 

763def test_native_update_manifest_only_banned_package_returns_directive(db): 

764 _add_ota_package( 

765 platform=OTAPlatform.ios, 

766 fingerprint="ios-fingerprint", 

767 version="v1.3.1.aaaa", 

768 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

769 banned=True, 

770 ) 

771 with real_bugs_session() as (bugs, _metadata_interceptor): 

772 res = bugs.GetNativeUpdateManifest( 

773 httpbody_pb2.HttpBody(), 

774 metadata=( 

775 ("eas-client-id", str(EAS_CLIENT_ID)), 

776 ("expo-platform", "ios"), 

777 ("expo-runtime-version", "ios-fingerprint"), 

778 ), 

779 ) 

780 

781 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"} 

782 

783 

784def test_native_update_manifest_without_runtime_version_returns_directive(db): 

785 _add_ota_package( 

786 platform=OTAPlatform.ios, 

787 fingerprint="ios-fingerprint", 

788 version="v1.3.1.aaaa", 

789 created_at=datetime(2026, 5, 31, tzinfo=UTC), 

790 ) 

791 with real_bugs_session() as (bugs, metadata_interceptor): 

792 res = bugs.GetNativeUpdateManifest( 

793 httpbody_pb2.HttpBody(), 

794 metadata=( 

795 ("eas-client-id", str(EAS_CLIENT_ID)), 

796 ("expo-platform", "ios"), 

797 ), 

798 ) 

799 

800 body = res.data.decode() 

801 assert _multipart_part_json(body, "directive") == {"type": "noUpdateAvailable"} 

802 assert metadata_interceptor.latest_headers["expo-protocol-version"] == "1" 

803 

804 

805def test_native_update_manifest_no_package_returns_directive(db): 

806 with real_bugs_session() as (bugs, _metadata_interceptor): 

807 res = bugs.GetNativeUpdateManifest( 

808 httpbody_pb2.HttpBody(), 

809 metadata=( 

810 ("eas-client-id", str(EAS_CLIENT_ID)), 

811 ("expo-platform", "ios"), 

812 ("expo-runtime-version", "ios-fingerprint"), 

813 ), 

814 ) 

815 

816 assert _multipart_part_json(res.data.decode(), "directive") == {"type": "noUpdateAvailable"} 

817 

818 

819def _ota_check_req(*, created_at, update_id=""): 

820 ts = timestamp_pb2.Timestamp() 

821 ts.FromDatetime(created_at) 

822 return bugs_pb2.CheckNativeStatusReq( 

823 eas_client_id=str(EAS_CLIENT_ID), 

824 platform="ios", 

825 runtime_version="ios-fingerprint", 

826 launch_source="ota", 

827 update_id=update_id, 

828 created_at=ts, 

829 ) 

830 

831 

832def test_check_native_status_ota_block_with_newer_bundle(db): 

833 _add_ota_package( 

834 platform=OTAPlatform.ios, 

835 fingerprint="ios-fingerprint", 

836 version="v1.3.2.newer", 

837 created_at=datetime.now(UTC) - timedelta(days=1), 

838 ) 

839 with bugs_session() as bugs: 

840 res = bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40))) 

841 

842 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA 

843 assert res.update_info.required is True 

844 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_AGE 

845 

846 

847def test_check_native_status_ota_block_without_target_raises(db): 

848 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"): 

849 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40))) 

850 

851 

852def test_check_native_status_ota_block_only_older_target_raises(db): 

853 _add_ota_package( 

854 platform=OTAPlatform.ios, 

855 fingerprint="ios-fingerprint", 

856 version="v1.3.1.older", 

857 created_at=datetime.now(UTC) - timedelta(days=50), 

858 ) 

859 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"): 

860 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=40))) 

861 

862 

863def test_check_native_status_banned_ota_block_with_successor(db): 

864 _add_ota_package( 

865 platform=OTAPlatform.ios, 

866 fingerprint="ios-fingerprint", 

867 version="v1.bad", 

868 created_at=datetime.now(UTC) - timedelta(days=5), 

869 banned=True, 

870 ) 

871 _add_ota_package( 

872 platform=OTAPlatform.ios, 

873 fingerprint="ios-fingerprint", 

874 version="v1.good", 

875 created_at=datetime.now(UTC) - timedelta(days=1), 

876 ) 

877 with bugs_session() as bugs: 

878 res = bugs.CheckNativeStatus( 

879 _ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad") 

880 ) 

881 

882 assert res.update_info.action == bugs_pb2.NATIVE_UPDATE_ACTION_OTA 

883 assert res.update_info.required is True 

884 assert res.update_info.cause == bugs_pb2.NATIVE_UPDATE_CAUSE_BANNED 

885 

886 

887def test_check_native_status_banned_ota_block_no_successor_raises(db): 

888 _add_ota_package( 

889 platform=OTAPlatform.ios, 

890 fingerprint="ios-fingerprint", 

891 version="v1.bad", 

892 created_at=datetime.now(UTC) - timedelta(days=5), 

893 banned=True, 

894 ) 

895 with bugs_session() as bugs, pytest.raises(Exception, match="no newer bundle to move to"): 

896 bugs.CheckNativeStatus(_ota_check_req(created_at=datetime.now(UTC) - timedelta(days=5), update_id="id-v1.bad")) 

897 

898 

899def test_log_experiment_exposure(db): 

900 user, token = generate_user() 

901 

902 with bugs_session(token) as bugs: 

903 bugs.LogExperimentExposure( 

904 bugs_pb2.LogExperimentExposureReq( 

905 experiment_key="my_experiment", 

906 experiment_name="My Experiment", 

907 variation_id=1, 

908 variation_key="treatment", 

909 variation_name="Treatment", 

910 hash_attribute="id", 

911 hash_value=str(user.id), 

912 feature_id="my_feature", 

913 in_experiment=True, 

914 bucket=0.5, 

915 hash_used=True, 

916 sticky_bucket_used=False, 

917 ) 

918 ) 

919 

920 with session_scope() as session: 

921 exposure = session.execute(select(ExperimentExposure)).scalar_one() 

922 assert exposure.user_id == user.id 

923 assert exposure.experiment_key == "my_experiment" 

924 assert exposure.variation_id == 1 

925 assert exposure.source == ExposureSource.client 

926 assert exposure.data == { 

927 "experiment_name": "My Experiment", 

928 "variation_key": "treatment", 

929 "variation_name": "Treatment", 

930 "hash_attribute": "id", 

931 "hash_value": str(user.id), 

932 "bucket": 0.5, 

933 "in_experiment": True, 

934 "hash_used": True, 

935 "sticky_bucket_used": False, 

936 "feature_id": "my_feature", 

937 } 

938 

939 

940def test_log_experiment_exposure_deduped(db): 

941 user, token = generate_user() 

942 

943 with bugs_session(token) as bugs: 

944 for _ in range(3): 

945 bugs.LogExperimentExposure( 

946 bugs_pb2.LogExperimentExposureReq( 

947 experiment_key="my_experiment", 

948 variation_id=1, 

949 variation_key="treatment", 

950 hash_attribute="id", 

951 hash_value=str(user.id), 

952 ) 

953 ) 

954 

955 with session_scope() as session: 

956 exposure = session.execute(select(ExperimentExposure)).scalar_one() 

957 # unset optional fields are stored as null, not a misleading 0/false 

958 assert exposure.data["bucket"] is None 

959 assert exposure.data["hash_used"] is None 

960 assert exposure.data["sticky_bucket_used"] is None 

961 

962 

963def test_log_experiment_exposure_anonymous_ignored(db): 

964 with bugs_session() as bugs: 

965 bugs.LogExperimentExposure( 

966 bugs_pb2.LogExperimentExposureReq( 

967 experiment_key="my_experiment", 

968 variation_id=1, 

969 variation_key="treatment", 

970 hash_attribute="id", 

971 hash_value="123", 

972 ) 

973 ) 

974 

975 with session_scope() as session: 

976 count = session.execute(select(func.count()).select_from(ExperimentExposure)).scalar_one() 

977 assert count == 0