fix: follow-up test-script review findings (v3.5.14)
- The SCP80 counter write-back resolves the preset by NAME first (the run
snapshot prefers the name) and only falls back to an ICCID-looking
value: presets with digits in their names are found after a page reload
(the previous fallback ran cardsNormIccid on the name and gave up).
- testScriptProblem validates the *selected* SCP80 source (like the form
and the server), so source=apdu with only sp filled is caught locally
instead of failing with a server 400.
- _test_run_start cleans up (_TEST_RUNNING=False, run state error) when
the worker thread cannot be created/started, and the endpoint answers
500 with a clear error instead of leaving the card blocked behind a run
that never started.
- The 5 s poll writes the counter back whenever a run is finished
(idempotent), so a reloaded page saves it without visiting the pill.
- Mask wildcards ('?') are stripped from the hex fields that cannot carry
a mask (APDU, secured packet, event/file data, TAR/SPI overrides, DCS,
extra TLVs); check values keep them.
Tests: frontend +3 (write-back by name / by ICCID, mask-free fields,
source-aware script check), Python +1 (failed thread start unblocks).
586 frontend / 471 Python green; version 3.5.14; sw simple-v267.
This commit is contained in:
+25
-15
@@ -1616,7 +1616,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// 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;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -6479,7 +6479,11 @@ function testScriptProblem(script) {
|
|||||||
if (TEST_ACTION_KINDS.indexOf(s.kind) < 0) return at + t('unknown action kind');
|
if (TEST_ACTION_KINDS.indexOf(s.kind) < 0) return at + t('unknown action kind');
|
||||||
const p = s.params || {};
|
const p = s.params || {};
|
||||||
if (s.kind === 'apdu' && !String(p.apdu || '').trim()) return at + t('APDU is empty');
|
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 === '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');
|
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') {
|
} else if (s.type === 'expect') {
|
||||||
@@ -6928,33 +6932,34 @@ function testStepCollect() {
|
|||||||
if (!step) return;
|
if (!step) return;
|
||||||
const val = id => { const el = document.getElementById(id); return el ? el.value.trim() : ''; };
|
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 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; };
|
const num = (id, dflt) => { const v = parseInt(val(id), 10); return isNaN(v) ? dflt : v; };
|
||||||
if (step.type === 'action') {
|
if (step.type === 'action') {
|
||||||
const kind = val('test-step-kind') || step.kind;
|
const kind = val('test-step-kind') || step.kind;
|
||||||
step.kind = kind;
|
step.kind = kind;
|
||||||
const p = {};
|
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 === 'menu-select') { p.item_id = num('test-f-item', 1); }
|
||||||
else if (kind === 'file-write' || kind === 'file-read') {
|
else if (kind === 'file-write' || kind === 'file-read') {
|
||||||
p.path = val('test-f-path');
|
p.path = val('test-f-path');
|
||||||
p.mode = val('test-f-mode') || 'auto';
|
p.mode = val('test-f-mode') || 'auto';
|
||||||
const rec = num('test-f-record', 0);
|
const rec = num('test-f-record', 0);
|
||||||
if (rec) p.record = rec;
|
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') {
|
else if (kind === 'scp80') {
|
||||||
// the source is stored explicitly: switching it must not drop the
|
// the source is stored explicitly: switching it must not drop the
|
||||||
// value of the other source (and must stick when the field is empty)
|
// value of the other source (and must stick when the field is empty)
|
||||||
const prev = step.params || {};
|
const prev = step.params || {};
|
||||||
const src = val('test-f-src') || prev.source || (prev.sp ? 'sp' : 'apdu');
|
const src = val('test-f-src') || prev.source || (prev.sp ? 'sp' : 'apdu');
|
||||||
// keep both values: the field of the inactive source is not rendered
|
// 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 || '';
|
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 || '';
|
else p.apdu = prev.apdu || '';
|
||||||
p.source = src;
|
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); }
|
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
|
||||||
step.params = p;
|
step.params = p;
|
||||||
@@ -6984,8 +6989,8 @@ function testStepCollect() {
|
|||||||
const ri = num('test-f-ritem', 0);
|
const ri = num('test-f-ritem', 0);
|
||||||
if (ri) r.item_id = ri;
|
if (ri) r.item_id = ri;
|
||||||
const text = val('test-f-rtext');
|
const text = val('test-f-rtext');
|
||||||
if (text) { r.text = text; r.dcs = hex('test-f-rdcs') || '00'; }
|
if (text) { r.text = text; r.dcs = hexNoMask('test-f-rdcs') || '00'; }
|
||||||
const raw = hex('test-f-rraw');
|
const raw = hexNoMask('test-f-rraw');
|
||||||
if (raw) r.raw = raw;
|
if (raw) r.raw = raw;
|
||||||
step.respond = r;
|
step.respond = r;
|
||||||
step.on_fail = val('test-f-fail') || 'error';
|
step.on_fail = val('test-f-fail') || 'error';
|
||||||
@@ -7176,10 +7181,14 @@ function testWriteBackCounter() {
|
|||||||
const next = String(st.scp80_counter).toUpperCase();
|
const next = String(st.scp80_counter).toUpperCase();
|
||||||
let idx = _testLastPresetIdx;
|
let idx = _testLastPresetIdx;
|
||||||
if (idx < 0 && st.preset) {
|
if (idx < 0 && st.preset) {
|
||||||
// a run started elsewhere (or observed after a page reload): find the
|
// A run started elsewhere (or observed after a page reload): the run
|
||||||
// preset the snapshot names, by ICCID or by name
|
// snapshot prefers the preset name, so look that up first and only
|
||||||
const norm = cardsNormIccid(st.preset);
|
// fall back to an ICCID-looking value.
|
||||||
idx = norm ? cardsFindByIccid(norm) : cards.findIndex(c => (c.name || '') === st.preset);
|
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;
|
if (idx < 0) return;
|
||||||
const preset = cards[idx];
|
const preset = cards[idx];
|
||||||
@@ -10962,7 +10971,8 @@ function pysimStartBackendPoll() {
|
|||||||
_testRunState = ts;
|
_testRunState = ts;
|
||||||
if (wasRunning !== !!ts.running) pysimApplyAvailability();
|
if (wasRunning !== !!ts.running) pysimApplyAvailability();
|
||||||
if (isViewVisible('phone-sub-test')) testRenderRun(ts);
|
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 */ }
|
} catch (e) { /* ignore */ }
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v266';
|
const CACHE = 'simple-v267';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ eval(extractFunc(html, 'testFormSelect'));
|
|||||||
eval(extractFunc(html, 'testStepRender'));
|
eval(extractFunc(html, 'testStepRender'));
|
||||||
eval(extractFunc(html, 'testRenderChecks'));
|
eval(extractFunc(html, 'testRenderChecks'));
|
||||||
eval(extractFunc(html, 'testStepCollect'));
|
eval(extractFunc(html, 'testStepCollect'));
|
||||||
|
eval(extractFunc(html, 'testWriteBackCounter'));
|
||||||
eval(extractFunc(html, 'testChecksCollect'));
|
eval(extractFunc(html, 'testChecksCollect'));
|
||||||
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
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: () => {} };
|
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||||
|
|
||||||
function fakeForm(values) {
|
function fakeForm(values) {
|
||||||
@@ -82,6 +84,13 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
|
|||||||
assert.match(testScriptProblem({
|
assert.match(testScriptProblem({
|
||||||
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
|
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
|
||||||
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
|
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', () => {
|
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=""/);
|
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', () => {
|
test('the item text check offers contains/exact only', () => {
|
||||||
const els = {
|
const els = {
|
||||||
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.5.13"
|
version = "3.5.14"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
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
|
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)
|
_finish_pending_menu(server, server.scc)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write('TEST-RUN: finishing pending menu failed: %s\n' % e)
|
sys.stderr.write('TEST-RUN: finishing pending menu failed: %s\n' % e)
|
||||||
_TEST_THREAD = threading.Thread(target=_test_run_worker,
|
try:
|
||||||
args=(server, script, preset),
|
_TEST_THREAD = threading.Thread(target=_test_run_worker,
|
||||||
name='test-script', daemon=True)
|
args=(server, script, preset),
|
||||||
_TEST_THREAD.start()
|
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):
|
class PysimHandler(BaseHTTPRequestHandler):
|
||||||
@@ -4931,7 +4943,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(resp, 400)
|
self._send_json(resp, 400)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
return
|
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()
|
resp = _test_state_snapshot()
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
|
|||||||
@@ -316,6 +316,17 @@ class TestRunnerDialogue(RunnerTestCase):
|
|||||||
self.assertEqual(sum(1 for a in scc.sent if a.startswith('80F2')), 3)
|
self.assertEqual(sum(1 for a in scc.sent if a.startswith('80F2')), 3)
|
||||||
self.assertEqual(run['steps'][0]['sent'], 'STATUS x3')
|
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):
|
def test_stop_before_the_first_step(self):
|
||||||
run = self.run_script(FakeServer(FakeScc()), [
|
run = self.run_script(FakeServer(FakeScc()), [
|
||||||
{'type': 'action', 'kind': 'status', 'params': {}},
|
{'type': 'action', 'kind': 'status', 'params': {}},
|
||||||
|
|||||||
Reference in New Issue
Block a user