11import json
22from datetime import UTC , datetime
3- from unittest .mock import patch
3+ from unittest .mock import MagicMock , patch
44
55import grpc
66import pytest
1313from couchers .models .logging import EventLog , EventSource
1414from couchers .proto import bugs_pb2
1515from couchers .proto .google .api import httpbody_pb2
16+ from couchers .servicers .bugs import _fetch_signed_manifest
1617from tests .fixtures .db import generate_user
1718from tests .fixtures .sessions import bugs_session , real_bugs_session
1819
@@ -403,56 +404,80 @@ def _multipart_part_json(body, name):
403404 return json .loads (body [start :end ])
404405
405406
406- _OTA_BUNDLES = {
407- "ios" : {
408- "id" : "00000000-0000-0000-0000-000000000000" ,
409- "launch_asset" : {"key" : "ios-key" , "url" : "https://cdn.example/ios/bundle.hbc" },
410- "assets" : [],
411- },
412- "android" : {
413- "id" : "00000000-0000-0000-0000-000000000000" ,
414- "launch_asset" : {"key" : "android-key" , "url" : "https://cdn.example/android/bundle.hbc" },
415- "assets" : [],
416- },
407+ _OTA_RELEASES = {
408+ "ios" : {"version" : "v1.2.18355.fc38c23d" , "runtime_version" : "ios-fingerprint" },
409+ "android" : {"version" : "v1.2.18356.ab12cd34" , "runtime_version" : "android-fingerprint" },
417410}
418411
412+ # A stand-in for the pre-signed multipart body the CDN serves; the servicer must hand it back
413+ # byte-for-byte, signature and all, so the test asserts on identity rather than on its contents.
414+ _SIGNED_MANIFEST = b'--COUCHERS_OTA_BOUNDARY\r \n content-disposition: form-data; name="manifest"\r \n expo-signature: sig="abc", keyid="main", alg="rsa-v1_5-sha256"\r \n \r \n {}\r \n --COUCHERS_OTA_BOUNDARY--\r \n '
415+ _CDN_CONTENT_TYPE = "multipart/mixed; boundary=COUCHERS_OTA_BOUNDARY"
416+
417+
418+ def _fake_cdn_response ():
419+ response = MagicMock ()
420+ response .headers = {"content-type" : _CDN_CONTENT_TYPE }
421+ response .content = _SIGNED_MANIFEST
422+ response .raise_for_status = MagicMock ()
423+ return response
424+
425+
426+ def test_native_update_manifest_serves_cdn_manifest_verbatim (db , feature_flags ):
427+ feature_flags .set ("native_ota_bundles" , _OTA_RELEASES )
428+ feature_flags .set ("native_ota_cdn_root" , "https://cdn.testing.invalid/native/ota" )
429+ _fetch_signed_manifest .cache_clear ()
430+ with patch ("couchers.servicers.bugs.requests.get" , return_value = _fake_cdn_response ()) as mock_get :
431+ with real_bugs_session () as (bugs , metadata_interceptor ):
432+ res = bugs .GetNativeUpdateManifest (
433+ httpbody_pb2 .HttpBody (),
434+ metadata = (("expo-platform" , "ios" ), ("expo-runtime-version" , "ios-fingerprint" )),
435+ )
419436
420- def test_native_update_manifest (db , feature_flags ):
421- feature_flags .set ("native_ota_bundles" , _OTA_BUNDLES )
422- with real_bugs_session () as (bugs , metadata_interceptor ):
423- res = bugs .GetNativeUpdateManifest (
424- httpbody_pb2 .HttpBody (),
425- metadata = (("expo-platform" , "ios" ), ("expo-runtime-version" , "my-fingerprint" )),
426- )
427-
428- assert res .content_type .startswith ("multipart/mixed; boundary=" )
429- body = res .data .decode ()
430-
431- manifest = _multipart_part_json (body , "manifest" )
432- # the manifest echoes the build's fingerprint so the client never rejects it
433- assert manifest ["runtimeVersion" ] == "my-fingerprint"
434- assert manifest ["launchAsset" ]["contentType" ] == "application/javascript"
435- assert manifest ["launchAsset" ]["url" ].endswith ("/ios/bundle.hbc" )
436- assert _multipart_part_json (body , "extensions" ) == {"assetRequestHeaders" : {}}
437+ # the signed manifest is forwarded untouched, content type and bytes
438+ assert res .content_type == _CDN_CONTENT_TYPE
439+ assert res .data == _SIGNED_MANIFEST
440+ # fetched from the live release's immutable per-platform CDN path
441+ mock_get .assert_called_once ()
442+ assert mock_get .call_args .args [0 ] == "https://cdn.testing.invalid/native/ota/v1.2.18355.fc38c23d/ios/manifest"
437443
438444 # the client requires these response headers or it rejects the manifest
439445 assert metadata_interceptor .latest_headers ["expo-protocol-version" ] == "1"
440446 assert metadata_interceptor .latest_headers ["expo-sfv-version" ] == "0"
441447
442448
443449def test_native_update_manifest_android (db , feature_flags ):
444- feature_flags .set ("native_ota_bundles" , _OTA_BUNDLES )
445- with real_bugs_session () as (bugs , _metadata_interceptor ):
446- res = bugs .GetNativeUpdateManifest (
447- httpbody_pb2 .HttpBody (),
448- metadata = (("expo-platform" , "android" ), ("expo-runtime-version" , "fp" )),
449- )
450+ feature_flags .set ("native_ota_bundles" , _OTA_RELEASES )
451+ feature_flags .set ("native_ota_cdn_root" , "https://cdn.testing.invalid/native/ota" )
452+ _fetch_signed_manifest .cache_clear ()
453+ with patch ("couchers.servicers.bugs.requests.get" , return_value = _fake_cdn_response ()) as mock_get :
454+ with real_bugs_session () as (bugs , _metadata_interceptor ):
455+ bugs .GetNativeUpdateManifest (
456+ httpbody_pb2 .HttpBody (),
457+ metadata = (("expo-platform" , "android" ), ("expo-runtime-version" , "android-fingerprint" )),
458+ )
459+
460+ assert mock_get .call_args .args [0 ] == "https://cdn.testing.invalid/native/ota/v1.2.18356.ab12cd34/android/manifest"
461+
450462
451- manifest = _multipart_part_json (res .data .decode (), "manifest" )
452- assert manifest ["launchAsset" ]["url" ].endswith ("/android/bundle.hbc" )
463+ def test_native_update_manifest_runtime_mismatch_returns_directive (db , feature_flags ):
464+ feature_flags .set ("native_ota_bundles" , _OTA_RELEASES )
465+ _fetch_signed_manifest .cache_clear ()
466+ # the live release targets a different build fingerprint than this client is running
467+ with patch ("couchers.servicers.bugs.requests.get" ) as mock_get :
468+ with real_bugs_session () as (bugs , _metadata_interceptor ):
469+ res = bugs .GetNativeUpdateManifest (
470+ httpbody_pb2 .HttpBody (),
471+ metadata = (("expo-platform" , "ios" ), ("expo-runtime-version" , "some-other-fingerprint" )),
472+ )
473+
474+ assert _multipart_part_json (res .data .decode (), "directive" ) == {"type" : "noUpdateAvailable" }
475+ # a mismatch must not even fetch — the manifest would be rejected on this build
476+ mock_get .assert_not_called ()
453477
454478
455- def test_native_update_manifest_without_runtime_version_returns_directive (db ):
479+ def test_native_update_manifest_without_runtime_version_returns_directive (db , feature_flags ):
480+ feature_flags .set ("native_ota_bundles" , _OTA_RELEASES )
456481 with real_bugs_session () as (bugs , metadata_interceptor ):
457482 res = bugs .GetNativeUpdateManifest (
458483 httpbody_pb2 .HttpBody (),
@@ -462,3 +487,14 @@ def test_native_update_manifest_without_runtime_version_returns_directive(db):
462487 body = res .data .decode ()
463488 assert _multipart_part_json (body , "directive" ) == {"type" : "noUpdateAvailable" }
464489 assert metadata_interceptor .latest_headers ["expo-protocol-version" ] == "1"
490+
491+
492+ def test_native_update_manifest_unconfigured_platform_returns_directive (db , feature_flags ):
493+ feature_flags .set ("native_ota_bundles" , {})
494+ with real_bugs_session () as (bugs , _metadata_interceptor ):
495+ res = bugs .GetNativeUpdateManifest (
496+ httpbody_pb2 .HttpBody (),
497+ metadata = (("expo-platform" , "ios" ), ("expo-runtime-version" , "ios-fingerprint" )),
498+ )
499+
500+ assert _multipart_part_json (res .data .decode (), "directive" ) == {"type" : "noUpdateAvailable" }
0 commit comments