esim: drive the profile switch ourselves; guard the FCP metadata
Disabling a profile failed with 6985 and left the card stuck until an equip. pySim's send_apdu_checksw auto-handler keeps flushing proactive commands after the REFRESH TERMINAL RESPONSE; the card is then mid-switch and answers 6985 to the next FETCH, which propagated as a 500 and skipped the re-initialization. Per SGP.22 v2.6 5.7.16/5.7.17 a 91XX answer is the ISD-R's 'result OK before REFRESH' (step 6) and the switch completes on the TERMINAL RESPONSE or the following RESET (step 8) - lpac treats 91XX the same way and never retries. - esim.py: build_switch_apdu/parse_switch_response/switch_profile split out of set_profile_state; the switch is one raw STORE DATA via scc._tp.send_apdu, a 91XX runs our own FETCH/TR chain (status_poll=False) and is reported as ok, the STORE DATA is never retried and a chain failure still counts the accepted switch. - server.py: /api/esim/profile answers the REFRESH with our chain, then re-initializes the card and re-reads the profile list, returning verified/state_after; _handle_proactive_chain grew status_poll. - /api/status and /api/select: FCP metadata via _fcp_value - an ADF or a failed select (card with the active profile disabled) has no file_descriptor and used to crash the request handler; _get_file_type no longer raises either. - esim._restore logs a failed selection restore instead of swallowing it. - PWA: esimSwitchStatus shows the verified state / not-confirmed warning. - tests: the switch flow (9000 / 91XX / error SW / chain failure), the FCP guards and the status helper; docs and sw simple-v230.
This commit is contained in:
+123
-21
@@ -171,32 +171,73 @@ class EsimTests(unittest.TestCase):
|
||||
self.assertEqual(out['rat'], [{'ppr_ids': ['ppr1', 'ppr2'],
|
||||
'allowed_operators': [], 'ppr_flags': []}])
|
||||
|
||||
def test_set_profile_state_enable_uses_iccid_and_refresh(self):
|
||||
app, _ = make_app()
|
||||
self.patch([EnableProfileResp(children=[EnableResult(decoded='ok')])])
|
||||
out = esim.set_profile_state(app, 'enable', iccid='8970119000004002667')
|
||||
self.assertTrue(out['ok'])
|
||||
self.assertEqual(out['result'], 'ok')
|
||||
tlv = self.calls[0].to_tlv().hex().upper()
|
||||
self.assertIn('5A0A980711090000042066F7', tlv) # ProfileIdentifier/ICCID
|
||||
self.assertTrue(tlv.endswith('810101')) # RefreshFlag = 1
|
||||
def test_build_switch_apdu_encodes_iccid_and_refresh(self):
|
||||
apdu = esim.build_switch_apdu('enable', iccid='8970119000004002667')
|
||||
self.assertTrue(apdu.startswith('80E29100'))
|
||||
self.assertTrue(apdu.endswith('00'))
|
||||
self.assertIn('5A0A980711090000042066F7', apdu) # ProfileIdentifier/ICCID
|
||||
self.assertIn('810101', apdu) # RefreshFlag = 1
|
||||
apdu = esim.build_switch_apdu(
|
||||
'disable', isdp_aid='A0000005591010FFFFFFFF8900000100', refresh=False)
|
||||
self.assertIn('4F10A0000005591010FFFFFFFF8900000100', apdu)
|
||||
self.assertIn('810100', apdu) # RefreshFlag = 0
|
||||
|
||||
def test_set_profile_state_maps_cat_busy(self):
|
||||
app, _ = make_app()
|
||||
self.patch([DisableProfileResp(children=[DisableResult(decoded='catBusy')])])
|
||||
out = esim.set_profile_state(app, 'disable', iccid='8970119000004002667')
|
||||
def test_build_switch_apdu_validation(self):
|
||||
with self.assertRaises(esim.EsimError):
|
||||
esim.build_switch_apdu('delete', iccid='8970119000004002667')
|
||||
with self.assertRaises(esim.EsimError):
|
||||
esim.build_switch_apdu('enable')
|
||||
with self.assertRaises(esim.EsimError):
|
||||
esim.build_switch_apdu('enable', iccid='abc')
|
||||
|
||||
def test_parse_switch_response_maps_result_codes(self):
|
||||
resp = DisableProfileResp(children=[DisableResult(decoded='catBusy')])
|
||||
out = esim.parse_switch_response('disable', resp.to_tlv().hex())
|
||||
self.assertFalse(out['ok'])
|
||||
self.assertEqual(out['result'], 'catBusy')
|
||||
self.assertIn('busy', out['message'])
|
||||
out = esim.parse_switch_response('disable', '')
|
||||
self.assertFalse(out['ok'])
|
||||
self.assertEqual(out['result'], 'undefinedError')
|
||||
|
||||
def test_set_profile_state_validation(self):
|
||||
app, _ = make_app()
|
||||
with self.assertRaises(esim.EsimError):
|
||||
esim.set_profile_state(app, 'delete', iccid='8970119000004002667')
|
||||
with self.assertRaises(esim.EsimError):
|
||||
esim.set_profile_state(app, 'enable')
|
||||
with self.assertRaises(esim.EsimError):
|
||||
esim.set_profile_state(app, 'enable', iccid='abc')
|
||||
def test_switch_profile_parses_ok_response(self):
|
||||
sent = []
|
||||
resp = EnableProfileResp(children=[EnableResult(decoded='ok')])
|
||||
out = esim.switch_profile(
|
||||
lambda apdu: sent.append(apdu) or (resp.to_tlv().hex(), '9000'),
|
||||
lambda sw: self.fail('no chain expected'),
|
||||
'enable', iccid='8970119000004002667')
|
||||
self.assertTrue(out['ok'])
|
||||
self.assertEqual(out['result'], 'ok')
|
||||
self.assertFalse(out['refresh_seen'])
|
||||
self.assertEqual(len(sent), 1)
|
||||
|
||||
def test_switch_profile_treats_91xx_as_ok_and_runs_the_chain(self):
|
||||
chain = []
|
||||
out = esim.switch_profile(
|
||||
lambda apdu: ('', '9111'),
|
||||
lambda sw: chain.append(sw) or True,
|
||||
'disable', iccid='8970119000004002667')
|
||||
self.assertTrue(out['ok'])
|
||||
self.assertEqual(out['result'], 'ok')
|
||||
self.assertTrue(out['refresh_seen'])
|
||||
self.assertEqual(chain, ['9111'])
|
||||
|
||||
def test_switch_profile_chain_failure_keeps_the_accepted_switch(self):
|
||||
def boom(sw):
|
||||
raise RuntimeError('fetch failed')
|
||||
out = esim.switch_profile(lambda apdu: ('', '910f'), boom,
|
||||
'disable', iccid='8970119000004002667')
|
||||
self.assertTrue(out['ok'])
|
||||
self.assertFalse(out['refresh_seen'])
|
||||
|
||||
def test_switch_profile_reports_error_sw(self):
|
||||
out = esim.switch_profile(lambda apdu: ('', '6985'),
|
||||
lambda sw: self.fail('no chain expected'),
|
||||
'disable', iccid='8970119000004002667')
|
||||
self.assertFalse(out['ok'])
|
||||
self.assertEqual(out['sw'], '6985')
|
||||
self.assertEqual(out['result'], 'undefinedError')
|
||||
|
||||
def test_select_isdr_requires_an_euicc(self):
|
||||
app, _ = make_app(isdr=False)
|
||||
@@ -296,6 +337,67 @@ class EsimInfoDecodeTests(unittest.TestCase):
|
||||
self.assertEqual(out['root_ds_address'], 'testrootsmds.gsma.com')
|
||||
|
||||
|
||||
class SelectionMetadataTests(unittest.TestCase):
|
||||
"""Status/select FCP metadata must never crash when the card has no usable
|
||||
FCP (an ADF selected, or a failed select while the active profile is
|
||||
disabled)."""
|
||||
|
||||
class FakeLchan:
|
||||
def __init__(self, fcp):
|
||||
self.selected_file_fcp = fcp
|
||||
|
||||
def selected_file_size(self):
|
||||
return self.selected_file_fcp.get('file_size')
|
||||
|
||||
def selected_file_record_len(self):
|
||||
return self.selected_file_fcp['file_descriptor'].get('record_len')
|
||||
|
||||
def selected_file_num_of_rec(self):
|
||||
return self.selected_file_fcp['file_descriptor'].get('num_of_rec')
|
||||
|
||||
def selected_file_structure(self):
|
||||
return self.selected_file_fcp['file_descriptor']['file_descriptor_byte']['structure']
|
||||
|
||||
def selected_file_type(self):
|
||||
return self.selected_file_fcp['file_descriptor']['file_descriptor_byte'].get('file_type', 'ef')
|
||||
|
||||
def test_missing_fcp_yields_none(self):
|
||||
from pysim_simple_server import server
|
||||
lchan = self.FakeLchan(None)
|
||||
self.assertIsNone(server._fcp_value(lchan, 'selected_file_size'))
|
||||
self.assertIsNone(server._fcp_value(lchan, 'selected_file_record_len'))
|
||||
self.assertIsNone(server._fcp_value(lchan, 'selected_file_num_of_rec'))
|
||||
# an EF without FCP falls back to 'transparent' (the historical default)
|
||||
self.assertEqual(server._get_file_type(lchan, SimpleNamespace(name='EF.ICCID')),
|
||||
'transparent')
|
||||
|
||||
def test_adf_fcp_without_file_descriptor_yields_none(self):
|
||||
from pysim_simple_server import server
|
||||
lchan = self.FakeLchan({'file_size': None})
|
||||
self.assertIsNone(server._fcp_value(lchan, 'selected_file_record_len'))
|
||||
self.assertIsNone(server._fcp_value(lchan, 'selected_file_num_of_rec'))
|
||||
self.assertIsNone(server._get_file_type(lchan, SimpleNamespace(name='EF.ICCID')))
|
||||
|
||||
def test_usable_fcp_reports_values(self):
|
||||
from pysim_simple_server import server
|
||||
fcp = {'file_size': 10,
|
||||
'file_descriptor': {'record_len': 5, 'num_of_rec': 2,
|
||||
'file_descriptor_byte': {'structure': 'linear_fixed',
|
||||
'file_type': 'ef'}}}
|
||||
lchan = self.FakeLchan(fcp)
|
||||
self.assertEqual(server._fcp_value(lchan, 'selected_file_size'), 10)
|
||||
self.assertEqual(server._fcp_value(lchan, 'selected_file_record_len'), 5)
|
||||
self.assertEqual(server._get_file_type(lchan, SimpleNamespace(name='EF.ADN')),
|
||||
'linear_fixed')
|
||||
|
||||
def test_norm_iccid_drops_the_f_pad(self):
|
||||
from pysim_simple_server import server
|
||||
self.assertEqual(server._norm_iccid('8970119000004002667'),
|
||||
'8970119000004002667')
|
||||
self.assertEqual(server._norm_iccid('98 90 71 11 90 00 00 40 02 66 7F'),
|
||||
'989071119000004002667')
|
||||
|
||||
|
||||
class EsimRoutingTests(unittest.TestCase):
|
||||
def test_esim_routes_are_in_the_right_http_handlers(self):
|
||||
from pysim_simple_server import server
|
||||
|
||||
Reference in New Issue
Block a user