diff --git a/frontend/index.html b/frontend/index.html index 0860fe9..7e41e8b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1616,7 +1616,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.5.13'; +const SIMPLE_VERSION = '3.5.14'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -6479,7 +6479,11 @@ function testScriptProblem(script) { if (TEST_ACTION_KINDS.indexOf(s.kind) < 0) return at + t('unknown action kind'); const p = s.params || {}; if (s.kind === 'apdu' && !String(p.apdu || '').trim()) return at + t('APDU is empty'); - if (s.kind === 'scp80' && !String(p.apdu || p.sp || '').trim()) return at + t('SCP80 needs an APDU or a secured packet'); + if (s.kind === 'scp80') { + const src = p.source || (p.sp ? 'sp' : 'apdu'); + const value = src === 'sp' ? p.sp : p.apdu; + if (!String(value || '').trim()) return at + t('SCP80 needs an APDU or a secured packet'); + } if (s.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return at + t('item id must be 1..255'); if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty'); } else if (s.type === 'expect') { @@ -6928,33 +6932,34 @@ function testStepCollect() { if (!step) return; const val = id => { const el = document.getElementById(id); return el ? el.value.trim() : ''; }; const hex = id => val(id).replace(/[^0-9a-fA-F?]/g, '').toUpperCase(); + const hexNoMask = id => val(id).replace(/[^0-9a-fA-F]/g, '').toUpperCase(); const num = (id, dflt) => { const v = parseInt(val(id), 10); return isNaN(v) ? dflt : v; }; if (step.type === 'action') { const kind = val('test-step-kind') || step.kind; step.kind = kind; const p = {}; - if (kind === 'envelope') { p.event = num('test-f-event', 3); p.data = hex('test-f-data'); } + if (kind === 'envelope') { p.event = num('test-f-event', 3); p.data = hexNoMask('test-f-data'); } else if (kind === 'menu-select') { p.item_id = num('test-f-item', 1); } else if (kind === 'file-write' || kind === 'file-read') { p.path = val('test-f-path'); p.mode = val('test-f-mode') || 'auto'; const rec = num('test-f-record', 0); if (rec) p.record = rec; - if (kind === 'file-write') p.data = hex('test-f-fdata'); + if (kind === 'file-write') p.data = hexNoMask('test-f-fdata'); } - else if (kind === 'apdu') { p.apdu = hex('test-f-apdu'); } + else if (kind === 'apdu') { p.apdu = hexNoMask('test-f-apdu'); } else if (kind === 'scp80') { // the source is stored explicitly: switching it must not drop the // value of the other source (and must stick when the field is empty) const prev = step.params || {}; const src = val('test-f-src') || prev.source || (prev.sp ? 'sp' : 'apdu'); // keep both values: the field of the inactive source is not rendered - if (document.getElementById('test-f-sp')) p.sp = hex('test-f-sp'); + if (document.getElementById('test-f-sp')) p.sp = hexNoMask('test-f-sp'); else p.sp = prev.sp || ''; - if (document.getElementById('test-f-apdu')) p.apdu = hex('test-f-apdu'); + if (document.getElementById('test-f-apdu')) p.apdu = hexNoMask('test-f-apdu'); else p.apdu = prev.apdu || ''; p.source = src; - ['tar', 'spi1', 'spi2'].forEach(k => { const v = hex('test-f-' + k); if (v) p[k] = v; }); + ['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; }); } else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); } step.params = p; @@ -6984,8 +6989,8 @@ function testStepCollect() { const ri = num('test-f-ritem', 0); if (ri) r.item_id = ri; const text = val('test-f-rtext'); - if (text) { r.text = text; r.dcs = hex('test-f-rdcs') || '00'; } - const raw = hex('test-f-rraw'); + if (text) { r.text = text; r.dcs = hexNoMask('test-f-rdcs') || '00'; } + const raw = hexNoMask('test-f-rraw'); if (raw) r.raw = raw; step.respond = r; step.on_fail = val('test-f-fail') || 'error'; @@ -7176,10 +7181,14 @@ function testWriteBackCounter() { const next = String(st.scp80_counter).toUpperCase(); let idx = _testLastPresetIdx; if (idx < 0 && st.preset) { - // a run started elsewhere (or observed after a page reload): find the - // preset the snapshot names, by ICCID or by name - const norm = cardsNormIccid(st.preset); - idx = norm ? cardsFindByIccid(norm) : cards.findIndex(c => (c.name || '') === st.preset); + // A run started elsewhere (or observed after a page reload): the run + // snapshot prefers the preset name, so look that up first and only + // fall back to an ICCID-looking value. + idx = cards.findIndex(c => (c.name || '') === st.preset); + if (idx < 0) { + const norm = cardsNormIccid(st.preset); + if (norm && norm.length >= 5) idx = cardsFindByIccid(norm); + } } if (idx < 0) return; const preset = cards[idx]; @@ -10962,7 +10971,8 @@ function pysimStartBackendPoll() { _testRunState = ts; if (wasRunning !== !!ts.running) pysimApplyAvailability(); if (isViewVisible('phone-sub-test')) testRenderRun(ts); - if (wasRunning && !ts.running) testWriteBackCounter(); + // idempotent: saves the counter of a run finished elsewhere + if (!ts.running) testWriteBackCounter(); } } catch (e) { /* ignore */ } }, 5000); diff --git a/frontend/sw.js b/frontend/sw.js index b0e84f9..6462e17 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v266'; +const CACHE = 'simple-v267'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js index 8c05b7d..618e82d 100644 --- a/frontend/tests/testscripts.test.js +++ b/frontend/tests/testscripts.test.js @@ -43,9 +43,11 @@ eval(extractFunc(html, 'testFormSelect')); eval(extractFunc(html, 'testStepRender')); eval(extractFunc(html, 'testRenderChecks')); eval(extractFunc(html, 'testStepCollect')); +eval(extractFunc(html, 'testWriteBackCounter')); eval(extractFunc(html, 'testChecksCollect')); eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;' - + ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;'); + + ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;' + + ' var _testLastPresetIdx = -1;'); globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; function fakeForm(values) { @@ -82,6 +84,13 @@ test('testScriptProblem accepts good scripts and names bad ones', () => { assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/); assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/); + // the selected SCP80 source decides which value is required + assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', + params: { source: 'apdu', sp: 'AA' } }] }), /SCP80/); + assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', + params: { source: 'apdu', apdu: '80E2' } }] }), ''); + assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', + params: { source: 'sp', sp: 'AA', apdu: '80E2' } }] }), ''); }); test('testStepSummary renders actions', () => { @@ -185,6 +194,49 @@ test('the step form renders the chosen source and no pre-filled SW', () => { assert.match(body, /id="test-f-sw" value=""/); }); +test('hex fields strip mask wildcards, check values keep them', () => { + _testEditStep = { type: 'action', kind: 'apdu', params: {} }; + fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2??', + 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); + testStepCollect(); + assert.strictEqual(_testEditStep.params.apdu, '80E2'); + fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2', 'test-f-sw': '91??', + 'test-f-sw-mode': 'mask', 'test-f-cdata': '', 'test-f-fail': 'error' }); + testStepCollect(); + assert.deepStrictEqual(_testEditStep.check.sw, { mode: 'mask', value: '91??' }); +}); + +test('the counter write-back finds the preset by name after a reload', () => { + let saved = 0; + globalThis.cards = [{ name: 'Card 1', iccid: '8970119000004600098', cntr: '0000000A' }]; + globalThis.cardsSave = () => { saved++; }; + globalThis.ioStatus = () => {}; + // the name lookup must win: the ICCID helpers are not even called + globalThis.cardsNormIccid = () => { throw new Error('ICCID lookup for a name'); }; + globalThis.cardsFindByIccid = () => { throw new Error('ICCID lookup for a name'); }; + _testLastPresetIdx = -1; + _testRunState = { running: false, scp80_counter: '0000000B', preset: 'Card 1' }; + testWriteBackCounter(); + assert.strictEqual(cards[0].cntr, '0000000B'); + assert.strictEqual(saved, 1); + testWriteBackCounter(); // idempotent + assert.strictEqual(saved, 1); +}); + +test('the counter write-back falls back to an ICCID snapshot', () => { + let saved = 0; + globalThis.cards = [{ name: 'Other', iccid: '8970119000004600098', cntr: '0000000A' }]; + globalThis.cardsSave = () => { saved++; }; + globalThis.ioStatus = () => {}; + globalThis.cardsNormIccid = v => String(v).replace(/\D/g, ''); + globalThis.cardsFindByIccid = v => (globalThis.cardsNormIccid(v) === '8970119000004600098' ? 0 : -1); + _testLastPresetIdx = -1; + _testRunState = { running: false, scp80_counter: '0000000C', preset: '8970119000004600098' }; + testWriteBackCounter(); + assert.strictEqual(cards[0].cntr, '0000000C'); + assert.strictEqual(saved, 1); +}); + test('the item text check offers contains/exact only', () => { const els = { 'test-step-modal': { classList: { add: () => {}, remove: () => {} } }, diff --git a/pyproject.toml b/pyproject.toml index 3271f70..59e1fdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.5.13" +version = "3.5.14" 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 99b5f8d..6cd1e6c 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.5.13' +VERSION = '3.5.14' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -4598,10 +4598,22 @@ def _test_run_start(server, script, preset): _finish_pending_menu(server, server.scc) except Exception as e: sys.stderr.write('TEST-RUN: finishing pending menu failed: %s\n' % e) - _TEST_THREAD = threading.Thread(target=_test_run_worker, - args=(server, script, preset), - name='test-script', daemon=True) - _TEST_THREAD.start() + try: + _TEST_THREAD = threading.Thread(target=_test_run_worker, + args=(server, script, preset), + name='test-script', daemon=True) + _TEST_THREAD.start() + except Exception: + # A run that never starts must not leave the card blocked (the 409 + # guard keys off _TEST_RUNNING, and no worker would ever clear it). + _TEST_THREAD = None + _TEST_RUNNING = False + with _TEST_LOCK: + _TEST_RUN['running'] = False + _TEST_RUN['status'] = 'error' + _TEST_RUN['error'] = 'could not start the run worker' + _TEST_RUN['finished'] = time.time() + raise class PysimHandler(BaseHTTPRequestHandler): @@ -4931,7 +4943,13 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json(resp, 400) self._log_resp(resp) return - _test_run_start(self.server, script, preset) + try: + _test_run_start(self.server, script, preset) + except Exception as e: + resp = {'error': 'could not start the test run: %s' % e} + self._send_json(resp, 500) + self._log_resp(resp) + return resp = _test_state_snapshot() self._send_json(resp) self._log_resp(resp) diff --git a/tests/test_testscript.py b/tests/test_testscript.py index fd86b31..65711c8 100644 --- a/tests/test_testscript.py +++ b/tests/test_testscript.py @@ -316,6 +316,17 @@ class TestRunnerDialogue(RunnerTestCase): self.assertEqual(sum(1 for a in scc.sent if a.startswith('80F2')), 3) self.assertEqual(run['steps'][0]['sent'], 'STATUS x3') + def test_failed_thread_start_unblocks_the_card(self): + script = T.normalise_script({'steps': [ + {'type': 'action', 'kind': 'status', 'params': {}}]}, S._test_command_type) + server = FakeServer(FakeScc()) + with mock.patch.object(S.threading, 'Thread', side_effect=RuntimeError('no threads')): + with self.assertRaises(RuntimeError): + S._test_run_start(server, script, {}) + self.assertFalse(S._TEST_RUNNING) + self.assertFalse(S._TEST_RUN['running']) + self.assertEqual(S._TEST_RUN['status'], 'error') + def test_stop_before_the_first_step(self): run = self.run_script(FakeServer(FakeScc()), [ {'type': 'action', 'kind': 'status', 'params': {}},