v3.21.0: the test-runner tolerance batch + the proactive cleanup action
Five reported issues, plus the action that makes an explicit drain possible: - on_fail is honoured by every step failure: an expectation with no pending command (or a FETCH that returns nothing) fails as a Command/Fetch check at the step's level instead of raising past it; the default still stops the run, on_fail "warning" continues (the tolerant consume-if-pending form). - the run cleanup answers ok (0x00) and keeps draining while the TR answers 91XX (bounded); a cancel is only sent when a script asks for it - it tells the card the user aborted the session and pushes assertive applets onto an error path. - new `proactive` action: STATUS rounds consume every announced command (respond/first TERMINAL RESPONSE results, attempts/interval_ms bound) until the card answers 9000; an optional require asserts at least one drained command (an empty drain fails it), the final SW is the step's SW check and a still-pending command is handed to the next step. PWA editor + summary + validation, EN/RU help, docs, action-kind drift test. - the PoR response form follows the command's TAR: _tar_is_rm (preset role entries + the TS 101 220 Annex D ISD/RFM allocations) decides; a non-RM TAR's reply is always raw (TS 102 226 4) - no fabricated status word even when the bytes happen to parse (01 00 0B 91 ... used to read as SW 000B). - a secured data too short for the compact structure (a 2-byte applet reply) is recovered by _por_header_parse (the decode minus the compact parse; a malformed packet or a failed RC/CC/DS check still yields no por) and reported raw, so short applet replies are assertable via por.data. Docs: test-scripts.md, api.md, help EN/RU, repo AGENTS. sw cache simple-v396. Tests: 825 frontend + 755 Python green.
This commit is contained in:
@@ -25,6 +25,7 @@ from pysim_simple_server.server import (
|
||||
_build_tr,
|
||||
_decode_cmd,
|
||||
_decode_por,
|
||||
_tar_is_rm,
|
||||
_decode_tr,
|
||||
_log_proactive,
|
||||
_ota_reference,
|
||||
@@ -535,6 +536,59 @@ class TestDecodePor(unittest.TestCase):
|
||||
self.assertEqual(r['decoded']['last_status_word'], '6a86')
|
||||
self.assertEqual(r['secured_data'], '016A86')
|
||||
|
||||
def test_non_rm_tar_is_application_data(self):
|
||||
# v3.21.0: the response form follows the command's TAR. A non-RM TAR
|
||||
# answers with application data (TS 102 226 4); `01 00 0B 91 ...`
|
||||
# happens to parse as a compact response (count 1, SW 000B) and must
|
||||
# not be split into a fabricated status word (live 2026-10-05).
|
||||
raw = '027100000e0aaf4d010000000001000001000B919733525088F4'
|
||||
r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw, rm=False)
|
||||
self.assertEqual(r['response_type'], 'raw')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['secured_data'], '01000B919733525088F4')
|
||||
self.assertEqual(r['decoded']['last_response_data'], '01000B919733525088F4')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '')
|
||||
self.assertIsNone(r['decoded']['number_of_commands'])
|
||||
# without the TAR context the same bytes still parse as compact
|
||||
r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw)
|
||||
self.assertEqual(r['response_type'], 'compact')
|
||||
self.assertEqual(r['decoded']['last_status_word'], '000b')
|
||||
|
||||
def test_short_applet_reply_falls_back_to_raw(self):
|
||||
# a 2-byte secured data cannot be a compact response (TS 102 226 5.1.2
|
||||
# Table 5.1 needs a count plus two status bytes): pySim's decode raises
|
||||
# and the packet is reported raw instead of "undecodable"
|
||||
raw = '027100000e0aaf4d01000000000100000400'
|
||||
r = _decode_por('16', '01', '15', '15', '0000000001', K, K, raw)
|
||||
self.assertEqual(r['response_type'], 'raw')
|
||||
self.assertEqual(r['response_status'], 'por_ok')
|
||||
self.assertEqual(r['secured_data'], '0400')
|
||||
self.assertEqual(r['decoded']['last_response_data'], '0400')
|
||||
|
||||
|
||||
class TestTarIsRm(unittest.TestCase):
|
||||
"""v3.21.0: the response form follows the command's TAR - the preset's
|
||||
role entries plus the standard ISD/RFM allocations of TS 101 220 Annex D."""
|
||||
|
||||
def test_standard_allocations(self):
|
||||
for tar in ('000000', 'B00000', 'B00001', 'B00010', 'B20100',
|
||||
'B00120', 'B00130', 'B00140'):
|
||||
self.assertTrue(_tar_is_rm(None, tar), tar)
|
||||
|
||||
def test_application_allocations_are_not_rm(self):
|
||||
# the Annex D application TARs (USAT interpreter, multiplexing, CASD,
|
||||
# OMA) and any custom TAR: application-specific data (TS 102 226 4)
|
||||
for tar in ('AF4D01', 'B20000', 'B20200', 'B20201', 'B20202',
|
||||
'B20203', 'B00200'):
|
||||
self.assertFalse(_tar_is_rm(None, tar), tar)
|
||||
|
||||
def test_preset_roles_win(self):
|
||||
preset = {'tars': [{'role': 'isd', 'tar': 'A11300', 'msl': '16'},
|
||||
{'role': '', 'tar': 'AF4D01', 'msl': '16'}]}
|
||||
self.assertTrue(_tar_is_rm(preset, 'A11300'))
|
||||
self.assertFalse(_tar_is_rm(preset, 'AF4D01'))
|
||||
self.assertIsNone(_tar_is_rm(preset, ''))
|
||||
|
||||
|
||||
class TestPresetCounterSeed(unittest.TestCase):
|
||||
"""v3.20.0: the effective counter of an operation - explicit `cntr` wins,
|
||||
|
||||
+191
-3
@@ -85,6 +85,39 @@ class TestValidation(unittest.TestCase):
|
||||
self.assertEqual(check['sw'], {'mode': 'mask', 'value': '91??'})
|
||||
self.assertEqual(check['data'], {'mode': 'mask', 'value': 'AA??'})
|
||||
|
||||
def test_proactive_action_normalises(self):
|
||||
script = T.normalise_script({'steps': [
|
||||
{'type': 'action', 'kind': 'proactive',
|
||||
'params': {'respond': {'result': 'ok'}, 'first': {'result': 'cancel'},
|
||||
'attempts': 5, 'interval_ms': 0,
|
||||
'require': {'command': 'REFRESH', 'qualifier': '00'}}},
|
||||
]}, _resolver)
|
||||
p = script['steps'][0]['params']
|
||||
self.assertEqual(p['respond'], {'result': 0x00})
|
||||
self.assertEqual(p['first'], {'result': 0x10})
|
||||
self.assertEqual(p['attempts'], 5)
|
||||
self.assertEqual(p['interval_ms'], 0)
|
||||
self.assertEqual(p['require']['type'], 0x01)
|
||||
self.assertEqual(p['require']['name'], 'REFRESH')
|
||||
self.assertEqual(p['require']['qualifier'], {'mode': 'exact', 'value': '00'})
|
||||
# the defaults: ok / 3 attempts / 200 ms, no first, no require
|
||||
script = T.normalise_script({'steps': [
|
||||
{'type': 'action', 'kind': 'proactive'}]}, _resolver)
|
||||
p = script['steps'][0]['params']
|
||||
self.assertEqual(p['respond'], {'result': 0x00})
|
||||
self.assertNotIn('first', p)
|
||||
self.assertNotIn('require', p)
|
||||
self.assertEqual(p['attempts'], 3)
|
||||
self.assertEqual(p['interval_ms'], 200)
|
||||
# a bad require is refused
|
||||
for bad in ({'require': {'qualifier': '00'}},
|
||||
{'require': 'REFRESH'},
|
||||
{'require': {'command': 'NOT A COMMAND'}},
|
||||
{'attempts': 0}):
|
||||
with self.assertRaises(T.ScriptError, msg=repr(bad)):
|
||||
T.normalise_script({'steps': [{'type': 'action', 'kind': 'proactive',
|
||||
'params': bad}]}, _resolver)
|
||||
|
||||
def test_integer_fields_accept_decimals_with_leading_zeros_and_hex(self):
|
||||
script = T.normalise_script({'steps': [
|
||||
{'type': 'action', 'kind': 'status',
|
||||
@@ -440,7 +473,29 @@ class TestRunnerDialogue(RunnerTestCase):
|
||||
'checks': [{'kind': 'text', 'value': 'hello'}], 'respond': {}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'error')
|
||||
self.assertIn('no proactive command pending', run['steps'][0]['note'])
|
||||
self.assertEqual(run['steps'][0]['status'], 'error')
|
||||
check = run['steps'][0]['checks'][0]
|
||||
self.assertEqual(check['label'], 'Command')
|
||||
self.assertFalse(check['ok'])
|
||||
self.assertEqual(check['expected'], 'DISPLAY TEXT')
|
||||
self.assertEqual(check['actual'], '(none)')
|
||||
self.assertIn('no proactive command pending', check['detail'])
|
||||
|
||||
def test_expectation_warning_without_pending_continues(self):
|
||||
# on_fail "warning" is the tolerant consume-if-pending form (v3.21.0):
|
||||
# the empty poll warns and the script continues
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9000')
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'expect', 'command': 'ANY', 'respond': {},
|
||||
'on_fail': 'warning'},
|
||||
{'type': 'action', 'kind': 'status', 'params': {}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'warning')
|
||||
self.assertEqual(len(run['steps']), 2)
|
||||
self.assertEqual(run['steps'][0]['status'], 'warning')
|
||||
self.assertIn('no proactive command pending',
|
||||
run['steps'][0]['checks'][0]['detail'])
|
||||
|
||||
def test_text_mismatch_fails_the_expectation(self):
|
||||
scc = FakeScc()
|
||||
@@ -471,9 +526,31 @@ class TestRunnerDialogue(RunnerTestCase):
|
||||
])
|
||||
self.assertEqual(run['status'], 'error')
|
||||
self.assertIn('unexpected proactive command pending', run['steps'][1]['note'])
|
||||
# the drain FETCHed the command and answered with a cancel TR (0x10)
|
||||
# the drain FETCHed the command and answered with an ok TR (0x00,
|
||||
# v3.21.0: a cancel told the card the user aborted the session)
|
||||
self.assertTrue(any(a.startswith('8012') for a in scc.sent[1:]))
|
||||
self.assertTrue(any('83021000' in a for a in scc.sent))
|
||||
self.assertTrue(any('83020000' in a for a in scc.sent))
|
||||
self.assertIn('TR ok', run['steps'][1]['sent'])
|
||||
|
||||
def test_drain_answers_ok_and_follows_the_next_command(self):
|
||||
# answering one command can make the application emit another; the
|
||||
# drain keeps fetching while the TR answers 91XX (bounded, v3.21.0)
|
||||
scc = FakeScc()
|
||||
scc.push('80C2', '', '9103') # menu selection
|
||||
scc.push('8012', SELECT_ITEM_CMD, '9000') # FETCH #1
|
||||
scc.push('8014', '', '9102') # TR -> another pending
|
||||
scc.push('8012', DISPLAY_TEXT_CMD, '9000') # FETCH #2
|
||||
scc.push('8014', '', '9000') # TR -> idle
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'menu-select', 'params': {'item_id': 1},
|
||||
'check': {'sw': '91??'}},
|
||||
{'type': 'action', 'kind': 'apdu', 'params': {'apdu': '00A40000023F00'}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'error') # the unexpected pending stops the script
|
||||
entry = run['steps'][1]
|
||||
self.assertEqual(entry['sent'].count('TR ok'), 2)
|
||||
self.assertEqual(entry['sw'], '9000')
|
||||
self.assertIn(SELECT_ITEM_CMD, entry['data'])
|
||||
|
||||
def test_sw_mismatch_error_stops_the_script_warning_continues(self):
|
||||
scc = FakeScc()
|
||||
@@ -532,6 +609,117 @@ class TestRunnerDialogue(RunnerTestCase):
|
||||
self.assertEqual(run['steps'], [])
|
||||
|
||||
|
||||
class TestRunnerProactive(RunnerTestCase):
|
||||
"""The proactive cleanup action (v3.21.0): consume whatever the card
|
||||
announces, answer it and confirm the card is idle."""
|
||||
|
||||
def test_empty_drain_passes(self):
|
||||
# the point of the action: an empty first poll is fine
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9000')
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive', 'params': {}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||
entry = run['steps'][0]
|
||||
self.assertEqual(entry['status'], 'ok')
|
||||
self.assertEqual(entry['sent'], 'STATUS x1; TR x0')
|
||||
self.assertEqual(entry['drained'], [])
|
||||
self.assertEqual(entry['checks'][0]['label'], 'SW')
|
||||
self.assertTrue(entry['checks'][0]['ok'])
|
||||
|
||||
def test_drains_a_pending_command_and_confirms_idle(self):
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9102') # STATUS: DISPLAY TEXT pending
|
||||
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
|
||||
scc.push('8014', '', '9000') # TR ok
|
||||
scc.push('80F2', '', '9000') # confirming STATUS: idle
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive', 'params': {'attempts': 3, 'interval_ms': 0}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||
entry = run['steps'][0]
|
||||
self.assertEqual(entry['sent'], 'STATUS x2; TR x1')
|
||||
self.assertEqual(len(entry['drained']), 1)
|
||||
self.assertEqual(entry['drained'][0]['type_name'], 'DISPLAY TEXT')
|
||||
self.assertEqual(entry['drained'][0]['qualifier'], '00')
|
||||
self.assertTrue(any('83020000' in a for a in scc.sent))
|
||||
|
||||
def test_require_matches_a_drained_command(self):
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9102')
|
||||
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
|
||||
scc.push('8014', '', '9000')
|
||||
scc.push('80F2', '', '9000')
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive',
|
||||
'params': {'attempts': 3, 'interval_ms': 0, 'require': {'command': 'DISPLAY TEXT'}}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||
req = [c for c in run['steps'][0]['checks'] if c['label'] == 'Require'][0]
|
||||
self.assertTrue(req['ok'])
|
||||
|
||||
def test_require_fails_on_a_mismatch(self):
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9102')
|
||||
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
|
||||
scc.push('8014', '', '9000')
|
||||
scc.push('80F2', '', '9000')
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive',
|
||||
'params': {'attempts': 3, 'interval_ms': 0,
|
||||
'require': {'command': 'REFRESH', 'qualifier': '00'}}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'error')
|
||||
req = [c for c in run['steps'][0]['checks'] if c['label'] == 'Require'][0]
|
||||
self.assertFalse(req['ok'])
|
||||
self.assertIn('DISPLAY TEXT', req['actual'])
|
||||
|
||||
def test_require_fails_on_an_empty_drain(self):
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9000')
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive',
|
||||
'params': {'require': {'command': 'REFRESH'}}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'error')
|
||||
req = [c for c in run['steps'][0]['checks'] if c['label'] == 'Require'][0]
|
||||
self.assertEqual(req['actual'], '(nothing was pending)')
|
||||
|
||||
def test_first_uses_a_different_result(self):
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9102') # STATUS: DISPLAY TEXT pending
|
||||
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
|
||||
scc.push('8014', '', '9102') # TR cancel -> another pending
|
||||
scc.push('80F2', '', '9102') # STATUS: SELECT ITEM pending
|
||||
scc.push('8012', SELECT_ITEM_CMD, '9000')
|
||||
scc.push('8014', '', '9000') # TR ok
|
||||
scc.push('80F2', '', '9000') # confirming STATUS
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive',
|
||||
'params': {'attempts': 4, 'interval_ms': 0, 'respond': {'result': 'ok'},
|
||||
'first': {'result': 'cancel'}}},
|
||||
])
|
||||
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||
self.assertEqual(len(run['steps'][0]['drained']), 2)
|
||||
self.assertTrue(any('83021000' in a for a in scc.sent)) # the first TR
|
||||
self.assertTrue(any('83020000' in a for a in scc.sent)) # the second
|
||||
|
||||
def test_bound_leaves_the_pending_for_the_next_step(self):
|
||||
scc = FakeScc()
|
||||
scc.push('80F2', '', '9102')
|
||||
scc.push('8012', DISPLAY_TEXT_CMD, '9000')
|
||||
scc.push('8014', '', '9102') # another command pending
|
||||
run = self.run_script(FakeServer(scc), [
|
||||
{'type': 'action', 'kind': 'proactive', 'params': {'attempts': 1}},
|
||||
])
|
||||
# the bound stopped with 91XX: the SW check fails and the pending is
|
||||
# handed to the next step
|
||||
self.assertEqual(run['status'], 'error')
|
||||
self.assertEqual(run['steps'][0]['sw'], '9102')
|
||||
self.assertFalse(run['steps'][0]['checks'][0]['ok'])
|
||||
|
||||
|
||||
class TestRunnerActions(RunnerTestCase):
|
||||
def test_script_menu_select_mirrors_menu_active(self):
|
||||
# 9000 -> the menu dialogue is over
|
||||
|
||||
Reference in New Issue
Block a user