From 00675ca09c1e2059933a5a27f03961240b38f165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 7 Oct 2026 00:53:20 +0300 Subject: [PATCH] v3.22.7: a raised card error honours the step's on_fail The runner's step loop caught an exception and hard-coded status "error" + stop, ignoring on_fail - so a file-read of an absent optional file (re-verified live on 3.22.6: MF/A153/49AC, note "6a82: Wrong parameters - File not found", on_fail "warning" still stopped the run) could not express the "read an optional file" pattern (49AC is documented optional: its absence means no menu password). The handler now treats a raised error as a failed "Error" check at the step's level (the message as actual/detail and the note): on_fail "warning" warns and the run continues, the default still stops; the drain-if-pending behaviour is unchanged. Every raised exception is treated the same - the two "file absent" raise paths (pySim's SwMatchError for model files and probe_file's RuntimeError with the decoded SW for custom FIDs) cannot be told apart by type, and load-time validation (normalise_script) stays strict. Docs: the guide's on_fail paragraph and run-errors list (the 6A82 bullet), api.md and help EN/RU. Tests: a raising file-read with on_fail "warning" warns and the next step runs; without on_fail the run still stops. 843 frontend + 821 Python; sw cache simple-v411. --- docs/api.md | 4 +++- docs/test-scripts.md | 11 +++++++-- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/server.py | 21 ++++++++++++----- tests/test_testscript.py | 44 +++++++++++++++++++++++++++++++++++ 9 files changed, 76 insertions(+), 14 deletions(-) diff --git a/docs/api.md b/docs/api.md index b8ff33b..6298a5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -753,7 +753,9 @@ step left pending (`91XX`). the decoded PoR of this exchange (inline or SEND SHORT MESSAGE; at least one field). `on_fail` is `error` (terminates the script) or `warning` (continues) and applies to every step failure - including an expectation that finds no -pending command (v3.21.0; `warning` is the tolerant consume-if-pending form). +pending command (v3.21.0; `warning` is the tolerant consume-if-pending form) +and a card/transport error raised during the step (v3.22.7 - the error is +reported as a failed `Error` check at the step's level). **Expectation steps** (`type: "expect"`) require a command pending from the previous step (`91XX`); they never poll - a `9000` response means no command diff --git a/docs/test-scripts.md b/docs/test-scripts.md index d5490c6..afae0b6 100644 --- a/docs/test-scripts.md +++ b/docs/test-scripts.md @@ -229,8 +229,11 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One' Every step may carry `on_fail`: `"error"` (default - stops the run) or `"warning"` (continues, the report shows ⚠). Each entry of `checks` may override it with its own `on_fail`. The level applies to **every** step -failure, including an expectation that finds no pending command (v3.21.0) - -the tolerant "consume a command if there is one" form is an expectation with +failure, including an expectation that finds no pending command (v3.21.0) +and a **card error raised during the step** (v3.22.7 - an absent optional +file's `6A82`, a refused SELECT: with `on_fail: "warning"` the step warns, +the report shows the error as a failed check, and the run continues) - the +tolerant "consume a command if there is one" form is an expectation with `on_fail: "warning"`; to drain and confirm the card is idle, use the `proactive-drain` action. @@ -649,6 +652,10 @@ PoR-in-submit (with the transport asserted by the expectation): names it). A `status` action with `attempts > 1` polls until a command appears when the card delivers on poll; `on_fail: "warning"` tolerates the empty case, and the `proactive-drain` action drains and confirms idle. +- `6A82: … File not found` (or another raised card error) - the step's + `on_fail` decides: the default stops the run, `on_fail: "warning"` warns + and continues - the pattern for an **optional** file (`49AC` is documented + optional: its absence means no menu password). - `menu-select: no menu item matches …` - the error lists the cached menu; a miss on an explicit `case_sensitive: true` names the case-insensitive candidates. diff --git a/frontend/help-ru.html b/frontend/help-ru.html index e57b781..a303002 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -566,7 +566,7 @@

Каждый шаг пишет семантическую строку в журнал сервера под своим номером (TEST-RUN step N: …), запрос перед ответом: отправленный APDU/данные и полученный SW, для SCP80 — открытый C-APDU, собранный защищённый пакет и R-APDU из PoR, для ожидания — тройка FETCH / команда / TERMINAL RESPONSE, очистка очереди проактивных команд — каждый раунд STATUS/FETCH/команда/TR, а очистка в конце запуска — поглощённые команды (отвечаются ok — cancel отправляется только по явному запросу скрипта), а для выбора пункта меню — полный ENVELOPE с определённым пунктом. Запустите сервер с --apdu-trace, чтобы добавить и журнал сырых транспортных APDU.

diff --git a/frontend/help.html b/frontend/help.html index 1b6ff07..50a92bb 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -565,7 +565,7 @@

Every step logs a semantic line to the server log under its step number (TEST-RUN step N: …), request before response: the APDU/data sent and the SW received, for SCP80 the plaintext C-APDU, the built secured packet and the PoR's R-APDU, for an expectation the FETCH / command / TERMINAL RESPONSE trio, a proactive drain every STATUS/FETCH/command/TR round and the run cleanup the commands it drained (answered ok — a cancel is only sent when a script asks for it), and for a menu selection the complete ENVELOPE with the resolved item. Start the server with --apdu-trace to add the raw transport APDU log as well.

diff --git a/frontend/index.html b/frontend/index.html index 94df0b6..4503a7b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1903,7 +1903,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.22.6'; +const SIMPLE_VERSION = '3.22.7'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== diff --git a/frontend/sw.js b/frontend/sw.js index 49d20f5..df2de50 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v410'; +const CACHE = 'simple-v411'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index a20d909..8ec9d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.22.6" +version = "3.22.7" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index dd43f2d..3b0d54d 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.22.6' +VERSION = '3.22.7' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -7124,16 +7124,25 @@ def _test_run_script(server, script, preset, shared=None): # Error terminates the script; Warning and OK continue. break except Exception as e: - _test_entry_update(entry, { - 'status': 'error', 'note': str(e), - 'ms': int((time.time() - entry['started']) * 1000)}) - sys.stderr.write('TEST-RUN step %d failed: %s\n' % (index + 1, e)) + # A raised card/transport error is a step failure like any other: + # it honours the step's on_fail (v3.22.7). An absent optional + # file (6A82), a refused SELECT or a preset/keyset error then + # warns and the run continues, with the error as a failed check; + # the default (error) still stops the run. + level = step.get('on_fail') or 'error' + check = _test_check_result('Error', False, 'the step to run', + str(e), level, detail=str(e)) + _test_finish_entry(entry, {'status': level, 'checks': [check], + 'note': str(e)}) + sys.stderr.write('TEST-RUN step %d failed (%s): %s\n' + % (index + 1, level, e)) if pending is not None: with _CARD_LOCK: drained = _test_drain_pending(scc, pending) _test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'}) pending = None - break + if level == 'error': + break if pending is not None: with _CARD_LOCK: drained = _test_drain_pending(scc, pending) diff --git a/tests/test_testscript.py b/tests/test_testscript.py index 6123951..1774428 100644 --- a/tests/test_testscript.py +++ b/tests/test_testscript.py @@ -1690,6 +1690,50 @@ class TestRunnerActions(RunnerTestCase): self.assertEqual(lchan.written, 'AA55') self.assertEqual(run['steps'][1]['data'], 'AA55') + def test_a_raised_card_error_honours_on_fail(self): + # v3.22.7: a card error raised during a step (an absent optional + # file's 6A82, a refused SELECT) is a step failure like any other - + # on_fail "warning" warns and the run continues. + lchan = object() # _select_path (patched) raises before its use + server = FakeServer(FakeScc()) + server.app = type('App', (), {'rs': type('Rs', (), {'lchan': [lchan]})()})() + server.scc.push('80F2', '', '9000') + with mock.patch.object(S, '_select_path', side_effect=RuntimeError( + '6a82: Wrong parameters - File not found')): + run = self.run_script(server, [ + {'type': 'action', 'kind': 'file-read', + 'params': {'path': 'MF/A153/49AC'}, 'on_fail': 'warning'}, + {'type': 'action', 'kind': 'status', 'params': {}}, + ]) + self.assertEqual(run['status'], 'warning', run['steps']) + self.assertEqual(len(run['steps']), 2) + entry = run['steps'][0] + self.assertEqual(entry['status'], 'warning') + self.assertIn('6a82', entry['note']) + check = entry['checks'][0] + self.assertEqual(check['label'], 'Error') + self.assertFalse(check['ok']) + self.assertEqual(check['level'], 'warning') + self.assertIn('6a82', check['actual']) + # the following step ran + self.assertEqual(run['steps'][1]['status'], 'ok') + + def test_a_raised_card_error_still_stops_by_default(self): + lchan = object() # _select_path (patched) raises before its use + server = FakeServer(FakeScc()) + server.app = type('App', (), {'rs': type('Rs', (), {'lchan': [lchan]})()})() + with mock.patch.object(S, '_select_path', side_effect=RuntimeError( + '6a82: Wrong parameters - File not found')): + run = self.run_script(server, [ + {'type': 'action', 'kind': 'file-read', + 'params': {'path': 'MF/A153/49AC'}}, + {'type': 'action', 'kind': 'status', 'params': {}}, + ]) + self.assertEqual(run['status'], 'error', run['steps']) + self.assertEqual(len(run['steps']), 1) + self.assertEqual(run['steps'][0]['status'], 'error') + self.assertIn('6a82', run['steps'][0]['note']) + def test_run_guard_blocks_card_endpoints_only(self): S._TEST_RUNNING = True try: