fix: test-script review findings (v3.5.13)
PWA: - The SCP80 "Source" switch sticks: the choice is stored in `params.source`, switching keeps both values (the server honors the explicit source when both are present, and the form validates only the selected one). - The SW check field is empty by default (placeholder "default: 9000 (91?? when polling)"), so the server defaults apply - previously the pre-filled 9000 defeated the polling STATUS default and a card that announced a command made the step fail with "expected 9000". - Item text checks offer contains/exact only (the mask mode was rejected by the server validation). - The SCP80 counter is written back to the preset even when the run is observed after a page reload or was started elsewhere (resolved by ICCID or preset name from the run snapshot). Server: - Step-entry mutations happen under `_TEST_LOCK` (`_test_entry_update`/`_test_finish_entry`): the status endpoint serializes the state with json.dumps, so entries must not change while it iterates them. - The pending-command drains (unexpected command, error, stop) hold `_CARD_LOCK` like every other card conversation. - `_int` accepts plain decimals with leading zeros and 0x hex. - The scripted TERMINAL RESPONSE text string uses the CR-set tag `8D` (consistent with the other TR TLVs; both are legal). - A script-driven menu selection mirrors `server.menu_active`. Tests: frontend +4 (source switch/round-trip, status SW default, render checks, item modes), Python +3 (integer parsing, menu_active, source selection). Help/docs unaffected beyond api.md's `source` note. 583 frontend / 470 Python green.
This commit is contained in:
+4
-3
@@ -394,9 +394,10 @@ suspended; only `/api/test/*`, `/api/status`, `/api/poll-status`,
|
|||||||
**Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`),
|
**Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`),
|
||||||
`menu-select` (`item_id` 1-255), `file-write` (`path`, `data`, `mode`
|
`menu-select` (`item_id` 1-255), `file-write` (`path`, `data`, `mode`
|
||||||
`auto`/`binary`/`record`, `record`), `file-read` (same, verifies `check.data`),
|
`auto`/`binary`/`record`, `record`), `file-read` (same, verifies `check.data`),
|
||||||
`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, optional
|
`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by
|
||||||
`tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always come from the
|
the optional `source` field when both are present; optional `tar`/`spi1`/`spi2`
|
||||||
`preset`, which must match the equipped card and be complete) or `status`
|
overrides - KIc/KID and the counter always come from the `preset`, which must
|
||||||
|
match the equipped card and be complete) or `status`
|
||||||
(`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the
|
(`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the
|
||||||
mask `91??`, i.e. poll until the card announces a command).
|
mask `91??`, i.e. poll until the card announces a command).
|
||||||
|
|
||||||
|
|||||||
+36
-17
@@ -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.12';
|
const SIMPLE_VERSION = '3.5.13';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -6525,7 +6525,7 @@ function testStepSummary(step) {
|
|||||||
if (step.kind === 'envelope') s = 'ENVELOPE event=0x' + Number(p.event || 0).toString(16).toUpperCase().padStart(2, '0') + (p.data ? ' data=' + p.data : '');
|
if (step.kind === 'envelope') s = 'ENVELOPE event=0x' + Number(p.event || 0).toString(16).toUpperCase().padStart(2, '0') + (p.data ? ' data=' + p.data : '');
|
||||||
else if (step.kind === 'menu-select') s = 'ENVELOPE(Menu Selection) item=' + p.item_id;
|
else if (step.kind === 'menu-select') s = 'ENVELOPE(Menu Selection) item=' + p.item_id;
|
||||||
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
||||||
else if (step.kind === 'scp80') s = 'SCP80 ' + (p.sp ? 'sp' : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '');
|
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '');
|
||||||
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
||||||
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
|
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
|
||||||
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
|
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
|
||||||
@@ -6743,10 +6743,6 @@ function testFormSelect(id, value, options, onchange) {
|
|||||||
return html + '</select>';
|
return html + '</select>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testCheckModes(value) {
|
|
||||||
return [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}, {v: 'contains', l: t('Contains')}];
|
|
||||||
}
|
|
||||||
|
|
||||||
function testStepRender() {
|
function testStepRender() {
|
||||||
const modal = document.getElementById('test-step-modal');
|
const modal = document.getElementById('test-step-modal');
|
||||||
const title = document.getElementById('test-step-title');
|
const title = document.getElementById('test-step-title');
|
||||||
@@ -6776,9 +6772,10 @@ function testStepRender() {
|
|||||||
} else if (step.kind === 'apdu') {
|
} else if (step.kind === 'apdu') {
|
||||||
html += testFormRow('APDU (hex)', testFormInput('test-f-apdu', p.apdu, '00A4040008A000000151000000'));
|
html += testFormRow('APDU (hex)', testFormInput('test-f-apdu', p.apdu, '00A4040008A000000151000000'));
|
||||||
} else if (step.kind === 'scp80') {
|
} else if (step.kind === 'scp80') {
|
||||||
html += testFormRow('Source', testFormSelect('test-f-src', p.sp ? 'sp' : 'apdu', [
|
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||||||
|
html += testFormRow('Source', testFormSelect('test-f-src', src, [
|
||||||
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
|
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
|
||||||
if (p.sp) html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
|
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
|
||||||
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
|
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
|
||||||
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
|
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
|
||||||
html += testFormRow('SPI1 override (hex, optional)', testFormInput('test-f-spi1', p.spi1, '16'));
|
html += testFormRow('SPI1 override (hex, optional)', testFormInput('test-f-spi1', p.spi1, '16'));
|
||||||
@@ -6791,7 +6788,7 @@ function testStepRender() {
|
|||||||
const sw = c.sw || {};
|
const sw = c.sw || {};
|
||||||
html += testFormRow('Expected SW', '<div class="flex gap-2">'
|
html += testFormRow('Expected SW', '<div class="flex gap-2">'
|
||||||
+ testFormSelect('test-f-sw-mode', sw.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
+ testFormSelect('test-f-sw-mode', sw.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
||||||
+ testFormInput('test-f-sw', sw.value === undefined ? '9000' : sw.value, '9000 or 91??') + '</div>');
|
+ testFormInput('test-f-sw', sw.value === undefined ? '' : sw.value, t('default: 9000 (91?? when polling)')) + '</div>');
|
||||||
const cd = c.data || {};
|
const cd = c.data || {};
|
||||||
html += testFormRow('Expected data (hex, optional)', '<div class="flex gap-2">'
|
html += testFormRow('Expected data (hex, optional)', '<div class="flex gap-2">'
|
||||||
+ testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
+ testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
||||||
@@ -6868,7 +6865,8 @@ function testRenderChecks() {
|
|||||||
if (c.kind === 'item') {
|
if (c.kind === 'item') {
|
||||||
html += testFormInput('test-check-id-' + i, c.id, t('id'));
|
html += testFormInput('test-check-id-' + i, c.id, t('id'));
|
||||||
html += testFormInput('test-check-text-' + i, c.text, t('text'));
|
html += testFormInput('test-check-text-' + i, c.text, t('text'));
|
||||||
html += testFormSelect('test-check-mode-' + i, c.mode || 'contains', testCheckModes());
|
html += testFormSelect('test-check-mode-' + i, c.mode || 'contains',
|
||||||
|
[{v: 'contains', l: t('Contains')}, {v: 'exact', l: t('Exact')}]);
|
||||||
} else if (c.kind === 'raw') {
|
} else if (c.kind === 'raw') {
|
||||||
html += testFormSelect('test-check-mode-' + i, c.mode || 'mask', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}]);
|
html += testFormSelect('test-check-mode-' + i, c.mode || 'mask', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}]);
|
||||||
html += testFormInput('test-check-value-' + i, c.value, 'AA??');
|
html += testFormInput('test-check-value-' + i, c.value, 'AA??');
|
||||||
@@ -6946,9 +6944,16 @@ function testStepCollect() {
|
|||||||
}
|
}
|
||||||
else if (kind === 'apdu') { p.apdu = hex('test-f-apdu'); }
|
else if (kind === 'apdu') { p.apdu = hex('test-f-apdu'); }
|
||||||
else if (kind === 'scp80') {
|
else if (kind === 'scp80') {
|
||||||
const src = val('test-f-src') || 'apdu';
|
// the source is stored explicitly: switching it must not drop the
|
||||||
if (src === 'sp') p.sp = hex('test-f-sp');
|
// value of the other source (and must stick when the field is empty)
|
||||||
else p.apdu = hex('test-f-apdu');
|
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');
|
||||||
|
else p.sp = prev.sp || '';
|
||||||
|
if (document.getElementById('test-f-apdu')) p.apdu = hex('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 = hex('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); }
|
||||||
@@ -6993,7 +6998,10 @@ function testStepFormError() {
|
|||||||
if (step.type === 'action') {
|
if (step.type === 'action') {
|
||||||
const p = step.params || {};
|
const p = step.params || {};
|
||||||
if (step.kind === 'apdu' && !p.apdu) return t('APDU is empty');
|
if (step.kind === 'apdu' && !p.apdu) return t('APDU is empty');
|
||||||
if (step.kind === 'scp80' && !p.apdu && !p.sp) return t('SCP80 needs an APDU or a secured packet');
|
if (step.kind === 'scp80') {
|
||||||
|
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||||||
|
if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet');
|
||||||
|
}
|
||||||
if (step.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return t('item id must be 1..255');
|
if (step.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return t('item id must be 1..255');
|
||||||
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
|
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
|
||||||
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
|
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
|
||||||
@@ -7164,10 +7172,19 @@ async function testPollRun() {
|
|||||||
|
|
||||||
function testWriteBackCounter() {
|
function testWriteBackCounter() {
|
||||||
const st = _testRunState;
|
const st = _testRunState;
|
||||||
if (!st || st.running || !st.scp80_counter || _testLastPresetIdx < 0) return;
|
if (!st || st.running || !st.scp80_counter) return;
|
||||||
const preset = cards[_testLastPresetIdx];
|
|
||||||
if (!preset) return;
|
|
||||||
const next = String(st.scp80_counter).toUpperCase();
|
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);
|
||||||
|
}
|
||||||
|
if (idx < 0) return;
|
||||||
|
const preset = cards[idx];
|
||||||
|
if (!preset) return;
|
||||||
|
_testLastPresetIdx = idx;
|
||||||
if (String(preset.cntr || '').toUpperCase() === next) return;
|
if (String(preset.cntr || '').toUpperCase() === next) return;
|
||||||
preset.cntr = next;
|
preset.cntr = next;
|
||||||
cardsSave();
|
cardsSave();
|
||||||
@@ -7181,6 +7198,7 @@ async function testInit() {
|
|||||||
const st = await pysimFetch('/api/test/status');
|
const st = await pysimFetch('/api/test/status');
|
||||||
testRenderRun(st);
|
testRenderRun(st);
|
||||||
if (st && st.running) testStartRunTimer();
|
if (st && st.running) testStartRunTimer();
|
||||||
|
else testWriteBackCounter();
|
||||||
} catch (e) { /* server optional */ }
|
} catch (e) { /* server optional */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16324,6 +16342,7 @@ const LANG_RU = {
|
|||||||
'Interval (ms, when polling)': 'Интервал (мс, при опросе)',
|
'Interval (ms, when polling)': 'Интервал (мс, при опросе)',
|
||||||
'attempts must be at least 1': 'число попыток должно быть ≥ 1',
|
'attempts must be at least 1': 'число попыток должно быть ≥ 1',
|
||||||
'Expected SW': 'Ожидаемое SW',
|
'Expected SW': 'Ожидаемое SW',
|
||||||
|
'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)',
|
||||||
'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)',
|
'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)',
|
||||||
'PoR check': 'Проверка PoR',
|
'PoR check': 'Проверка PoR',
|
||||||
'Any': 'Любой',
|
'Any': 'Любой',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v265';
|
const CACHE = 'simple-v266';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ eval(extractFunc(html, 'testCheckSummary'));
|
|||||||
eval(extractFunc(html, 'testStepSummary'));
|
eval(extractFunc(html, 'testStepSummary'));
|
||||||
eval(extractFunc(html, 'testCommandOptions'));
|
eval(extractFunc(html, 'testCommandOptions'));
|
||||||
globalThis.t = s => s;
|
globalThis.t = s => s;
|
||||||
|
globalThis.esc = s => String(s);
|
||||||
|
eval(extractFunc(html, 'testFormRow'));
|
||||||
|
eval(extractFunc(html, 'testFormInput'));
|
||||||
|
eval(extractFunc(html, 'testFormSelect'));
|
||||||
|
eval(extractFunc(html, 'testStepRender'));
|
||||||
|
eval(extractFunc(html, 'testRenderChecks'));
|
||||||
|
eval(extractFunc(html, 'testStepCollect'));
|
||||||
|
eval(extractFunc(html, 'testChecksCollect'));
|
||||||
|
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
||||||
|
+ ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;');
|
||||||
|
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||||
|
|
||||||
|
function fakeForm(values) {
|
||||||
|
globalThis.document = { getElementById: id => (id in values ? { value: values[id] } : null) };
|
||||||
|
}
|
||||||
|
|
||||||
test('the templates contain usable steps', () => {
|
test('the templates contain usable steps', () => {
|
||||||
const menu = testTemplate('menu');
|
const menu = testTemplate('menu');
|
||||||
@@ -117,6 +132,77 @@ test('testCommandOptions covers the proactive names and keeps custom values', ()
|
|||||||
assert.ok(custom.some(o => o.v === '0x74'));
|
assert.ok(custom.some(o => o.v === '0x74'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the SCP80 source switch sticks and preserves the other value', () => {
|
||||||
|
_testEditStep = { type: 'action', kind: 'scp80', params: { apdu: '80E2900000' } };
|
||||||
|
_testEditChecks = [];
|
||||||
|
_testEditStepIndex = 0;
|
||||||
|
// switch to the pre-built packet: the packet field is not rendered yet
|
||||||
|
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'sp', 'test-f-apdu': '80E2900000',
|
||||||
|
'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'any', 'test-f-fail': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.strictEqual(_testEditStep.params.source, 'sp');
|
||||||
|
assert.strictEqual(_testEditStep.params.sp, '');
|
||||||
|
assert.strictEqual(_testEditStep.params.apdu, '80E2900000'); // preserved
|
||||||
|
assert.ok(!('sw' in _testEditStep.check)); // server default
|
||||||
|
// the packet field is rendered now and gets a value
|
||||||
|
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'sp', 'test-f-sp': 'aabbcc',
|
||||||
|
'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'none', 'test-f-fail': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
||||||
|
assert.strictEqual(_testEditStep.params.source, 'sp');
|
||||||
|
assert.strictEqual(_testEditStep.check.por, 'none');
|
||||||
|
// switching back keeps both values
|
||||||
|
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||||
|
'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'any', 'test-f-fail': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.strictEqual(_testEditStep.params.source, 'apdu');
|
||||||
|
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the STATUS step leaves the SW check to the server default', () => {
|
||||||
|
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
||||||
|
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
||||||
|
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.deepStrictEqual(_testEditStep.params, { attempts: 5, interval_ms: 200 });
|
||||||
|
assert.deepStrictEqual(_testEditStep.check, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the step form renders the chosen source and no pre-filled SW', () => {
|
||||||
|
const els = {
|
||||||
|
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
'test-step-title': {}, 'test-step-body': {},
|
||||||
|
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
_testEditStep = { type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AABB', apdu: '80E2' } };
|
||||||
|
_testEditChecks = [];
|
||||||
|
_testEditStepIndex = 0;
|
||||||
|
testStepRender();
|
||||||
|
const body = els['test-step-body'].innerHTML;
|
||||||
|
assert.match(body, /id="test-f-sp" value="AABB"/);
|
||||||
|
assert.match(body, /<option value="sp" selected>/);
|
||||||
|
assert.match(body, /id="test-f-sw" value=""/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the item text check offers contains/exact only', () => {
|
||||||
|
const els = {
|
||||||
|
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
'test-step-title': {}, 'test-step-body': {},
|
||||||
|
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
'test-checks': {},
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
_testEditStep = { type: 'expect', command: 'SELECT ITEM',
|
||||||
|
checks: [{ kind: 'item', text: 'test', mode: 'contains' }], respond: { result: 'ok' } };
|
||||||
|
_testEditChecks = [{ kind: 'item', text: 'test', mode: 'contains' }];
|
||||||
|
_testEditStepIndex = 0;
|
||||||
|
testStepRender();
|
||||||
|
const checks = els['test-checks'].innerHTML;
|
||||||
|
assert.match(checks, /<option value="contains" selected>/);
|
||||||
|
assert.ok(!checks.includes('value="mask"'), checks);
|
||||||
|
});
|
||||||
|
|
||||||
test('the Simulator hosts the Test script pill and its wiring', () => {
|
test('the Simulator hosts the Test script pill and its wiring', () => {
|
||||||
assert.match(html, /data-phone-sub="test"[^>]*data-l10n="Test script"/);
|
assert.match(html, /data-phone-sub="test"[^>]*data-l10n="Test script"/);
|
||||||
assert.match(html, /id="phone-sub-test"/);
|
assert.match(html, /id="phone-sub-test"/);
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.5.12"
|
version = "3.5.13"
|
||||||
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.12'
|
VERSION = '3.5.13'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -4217,24 +4217,22 @@ def _test_entry(step, index):
|
|||||||
'sw': None, 'data': None, 'ms': None, 'started': time.time()}
|
'sw': None, 'data': None, 'ms': None, 'started': time.time()}
|
||||||
|
|
||||||
|
|
||||||
|
def _test_entry_update(entry, fields):
|
||||||
|
"""Mutate a step entry under _TEST_LOCK: the status endpoint serializes
|
||||||
|
the whole run state with json.dumps, so entries must not change while it
|
||||||
|
iterates them."""
|
||||||
|
with _TEST_LOCK:
|
||||||
|
entry.update(fields)
|
||||||
|
|
||||||
|
|
||||||
def _test_finish_entry(entry, result):
|
def _test_finish_entry(entry, result):
|
||||||
entry['status'] = result.get('status', 'ok')
|
fields = {'status': result.get('status', 'ok'),
|
||||||
entry['checks'] = result.get('checks', [])
|
'checks': result.get('checks', []),
|
||||||
if result.get('sent') is not None:
|
'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)}
|
||||||
entry['sent'] = result['sent']
|
for key in ('sent', 'sw', 'data', 'note', 'por', 'counter', 'command'):
|
||||||
if result.get('sw') is not None:
|
if result.get(key) is not None:
|
||||||
entry['sw'] = result['sw']
|
fields[key] = result[key]
|
||||||
if result.get('data') is not None:
|
_test_entry_update(entry, fields)
|
||||||
entry['data'] = result['data']
|
|
||||||
if result.get('note'):
|
|
||||||
entry['note'] = result['note']
|
|
||||||
if result.get('por') is not None:
|
|
||||||
entry['por'] = result['por']
|
|
||||||
if result.get('counter'):
|
|
||||||
entry['counter'] = result['counter']
|
|
||||||
if result.get('command'):
|
|
||||||
entry['command'] = result['command']
|
|
||||||
entry['ms'] = int((time.time() - (entry.get('started') or time.time())) * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def _test_action_checks(step, sw, data, por, kind):
|
def _test_action_checks(step, sw, data, por, kind):
|
||||||
@@ -4356,7 +4354,10 @@ def _test_run_action(server, step, ctx):
|
|||||||
elif kind == 'menu-select':
|
elif kind == 'menu-select':
|
||||||
tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, p['item_id']])
|
tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, p['item_id']])
|
||||||
sent = '%sc20000%02x%s' % (scc.cat_cla, len(tlv), tlv.hex())
|
sent = '%sc20000%02x%s' % (scc.cat_cla, len(tlv), tlv.hex())
|
||||||
|
server.menu_active = True
|
||||||
data, sw = scc._tp.send_apdu(sent)
|
data, sw = scc._tp.send_apdu(sent)
|
||||||
|
if not (sw or '').startswith('91'):
|
||||||
|
server.menu_active = False
|
||||||
elif kind == 'apdu':
|
elif kind == 'apdu':
|
||||||
sent = p['apdu']
|
sent = p['apdu']
|
||||||
data, sw = scc._tp.send_apdu(sent)
|
data, sw = scc._tp.send_apdu(sent)
|
||||||
@@ -4469,6 +4470,7 @@ def _test_drain_pending(scc, fetch_len):
|
|||||||
|
|
||||||
|
|
||||||
def _test_run_execute(server, script, preset):
|
def _test_run_execute(server, script, preset):
|
||||||
|
global _TEST_RUNNING
|
||||||
scc = server.scc
|
scc = server.scc
|
||||||
ctx = {'preset': dict(preset or {}),
|
ctx = {'preset': dict(preset or {}),
|
||||||
'counter': str((preset or {}).get('counter') or (preset or {}).get('cntr') or '').upper(),
|
'counter': str((preset or {}).get('counter') or (preset or {}).get('cntr') or '').upper(),
|
||||||
@@ -4491,18 +4493,22 @@ def _test_run_execute(server, script, preset):
|
|||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN['steps'].append(entry)
|
_TEST_RUN['steps'].append(entry)
|
||||||
if pending is not None and step['type'] != 'expect':
|
if pending is not None and step['type'] != 'expect':
|
||||||
entry['status'] = 'error'
|
_test_entry_update(entry, {
|
||||||
entry['note'] = ('unexpected proactive command pending (SW 91XX) - '
|
'status': 'error',
|
||||||
'add an expect step or a status action')
|
'note': 'unexpected proactive command pending (SW 91XX) - '
|
||||||
drained = _test_drain_pending(scc, pending)
|
'add an expect step or a status action'})
|
||||||
entry.update({k: v for k, v in drained.items() if k != 'note'})
|
with _CARD_LOCK:
|
||||||
entry['ms'] = int((time.time() - entry['started']) * 1000)
|
drained = _test_drain_pending(scc, pending)
|
||||||
|
fields = {k: v for k, v in drained.items() if k != 'note'}
|
||||||
|
fields['ms'] = int((time.time() - entry['started']) * 1000)
|
||||||
|
_test_entry_update(entry, fields)
|
||||||
pending = None
|
pending = None
|
||||||
break
|
break
|
||||||
if getattr(server, 'card_session', 0) != session:
|
if getattr(server, 'card_session', 0) != session:
|
||||||
entry['status'] = 'error'
|
_test_entry_update(entry, {
|
||||||
entry['note'] = 'card session changed (card removed or re-equipped)'
|
'status': 'error',
|
||||||
entry['ms'] = int((time.time() - entry['started']) * 1000)
|
'note': 'card session changed (card removed or re-equipped)',
|
||||||
|
'ms': int((time.time() - entry['started']) * 1000)})
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
@@ -4515,17 +4521,19 @@ def _test_run_execute(server, script, preset):
|
|||||||
# Error terminates the script; Warning and OK continue.
|
# Error terminates the script; Warning and OK continue.
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
entry['status'] = 'error'
|
_test_entry_update(entry, {
|
||||||
entry['note'] = str(e)
|
'status': 'error', 'note': str(e),
|
||||||
entry['ms'] = int((time.time() - entry['started']) * 1000)
|
'ms': int((time.time() - entry['started']) * 1000)})
|
||||||
sys.stderr.write('TEST-RUN step %d failed: %s\n' % (index + 1, e))
|
sys.stderr.write('TEST-RUN step %d failed: %s\n' % (index + 1, e))
|
||||||
if pending is not None:
|
if pending is not None:
|
||||||
drained = _test_drain_pending(scc, pending)
|
with _CARD_LOCK:
|
||||||
entry.update({k: v for k, v in drained.items() if k != 'note'})
|
drained = _test_drain_pending(scc, pending)
|
||||||
|
_test_entry_update(entry, {k: v for k, v in drained.items() if k != 'note'})
|
||||||
pending = None
|
pending = None
|
||||||
break
|
break
|
||||||
if pending is not None:
|
if pending is not None:
|
||||||
drained = _test_drain_pending(scc, pending)
|
with _CARD_LOCK:
|
||||||
|
drained = _test_drain_pending(scc, pending)
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN['steps'].append(dict(
|
_TEST_RUN['steps'].append(dict(
|
||||||
{'index': len(script['steps']), 'type': 'cleanup', 'kind': 'cleanup',
|
{'index': len(script['steps']), 'type': 'cleanup', 'kind': 'cleanup',
|
||||||
@@ -4547,18 +4555,17 @@ def _test_run_execute(server, script, preset):
|
|||||||
_TEST_RUN['status'] = 'warning'
|
_TEST_RUN['status'] = 'warning'
|
||||||
else:
|
else:
|
||||||
_TEST_RUN['status'] = 'ok'
|
_TEST_RUN['status'] = 'ok'
|
||||||
global _TEST_RUNNING
|
|
||||||
_TEST_RUNNING = False
|
_TEST_RUNNING = False
|
||||||
if _POLL_ENABLED and not _POLL_DISABLED_BY_CARD:
|
if _POLL_ENABLED and not _POLL_DISABLED_BY_CARD:
|
||||||
_reset_poll_timer()
|
_reset_poll_timer()
|
||||||
|
|
||||||
|
|
||||||
def _test_run_worker(server, script, preset):
|
def _test_run_worker(server, script, preset):
|
||||||
|
global _TEST_RUNNING
|
||||||
try:
|
try:
|
||||||
_test_run_execute(server, script, preset)
|
_test_run_execute(server, script, preset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
global _TEST_RUNNING
|
|
||||||
_TEST_RUNNING = False
|
_TEST_RUNNING = False
|
||||||
with _TEST_LOCK:
|
with _TEST_LOCK:
|
||||||
_TEST_RUN['error'] = str(e)
|
_TEST_RUN['error'] = str(e)
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ def _fail_level(value, default='error'):
|
|||||||
|
|
||||||
def _int(value, what, lo, hi):
|
def _int(value, what, lo, hi):
|
||||||
try:
|
try:
|
||||||
v = int(str(value).strip(), 0)
|
text = str(value).strip()
|
||||||
|
v = int(text, 16 if text.lower().startswith('0x') else 10)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise ScriptError('%s must be an integer' % what)
|
raise ScriptError('%s must be an integer' % what)
|
||||||
if not lo <= v <= hi:
|
if not lo <= v <= hi:
|
||||||
@@ -233,12 +234,17 @@ def _normalise_params(kind, p):
|
|||||||
return {'apdu': _data_hex(p.get('apdu'), 'apdu')}
|
return {'apdu': _data_hex(p.get('apdu'), 'apdu')}
|
||||||
if kind == 'scp80':
|
if kind == 'scp80':
|
||||||
out = {}
|
out = {}
|
||||||
if p.get('sp'):
|
source = str(p.get('source') or '').lower()
|
||||||
|
if source not in ('', 'apdu', 'sp'):
|
||||||
|
raise ScriptError('scp80: source must be apdu or sp')
|
||||||
|
if source == 'sp' or (not source and p.get('sp')):
|
||||||
|
if not p.get('sp'):
|
||||||
|
raise ScriptError('scp80: secured packet is required')
|
||||||
out['sp'] = _data_hex(p.get('sp'), 'secured packet')
|
out['sp'] = _data_hex(p.get('sp'), 'secured packet')
|
||||||
elif p.get('apdu'):
|
|
||||||
out['apdu'] = _data_hex(p.get('apdu'), 'scp80 apdu')
|
|
||||||
else:
|
else:
|
||||||
raise ScriptError('scp80: apdu or sp is required')
|
if not p.get('apdu'):
|
||||||
|
raise ScriptError('scp80: apdu is required')
|
||||||
|
out['apdu'] = _data_hex(p.get('apdu'), 'scp80 apdu')
|
||||||
for key in ('tar', 'spi1', 'spi2'):
|
for key in ('tar', 'spi1', 'spi2'):
|
||||||
if p.get(key) not in (None, ''):
|
if p.get(key) not in (None, ''):
|
||||||
out[key] = _data_hex(p[key], 'scp80 %s' % key)
|
out[key] = _data_hex(p[key], 'scp80 %s' % key)
|
||||||
@@ -402,6 +408,6 @@ def build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond):
|
|||||||
if respond.get('text') is not None:
|
if respond.get('text') is not None:
|
||||||
dcs = int(respond.get('dcs', 0x00))
|
dcs = int(respond.get('dcs', 0x00))
|
||||||
body = _encode_text(respond['text'], dcs)
|
body = _encode_text(respond['text'], dcs)
|
||||||
out += bytes([0x0D, len(body) + 1, dcs]) + body
|
out += bytes([0x8D, len(body) + 1, dcs]) + body
|
||||||
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
|
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
|
||||||
return bytes(out)
|
return bytes(out)
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ class TestValidation(unittest.TestCase):
|
|||||||
self.assertEqual(check['sw'], {'mode': 'mask', 'value': '91??'})
|
self.assertEqual(check['sw'], {'mode': 'mask', 'value': '91??'})
|
||||||
self.assertEqual(check['data'], {'mode': 'mask', 'value': 'AA??'})
|
self.assertEqual(check['data'], {'mode': 'mask', 'value': 'AA??'})
|
||||||
|
|
||||||
|
def test_integer_fields_accept_decimals_with_leading_zeros_and_hex(self):
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'status',
|
||||||
|
'params': {'attempts': '08', 'interval_ms': '0x10'}},
|
||||||
|
]}, _resolver)
|
||||||
|
self.assertEqual(script['steps'][0]['params']['attempts'], 8)
|
||||||
|
self.assertEqual(script['steps'][0]['params']['interval_ms'], 16)
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'status', 'params': {'attempts': 'x'}}]}, _resolver)
|
||||||
|
|
||||||
def test_respond_text_and_raw(self):
|
def test_respond_text_and_raw(self):
|
||||||
respond = T.normalise_respond({'result': 'ok', 'text': 'hello', 'dcs': '00',
|
respond = T.normalise_respond({'result': 'ok', 'text': 'hello', 'dcs': '00',
|
||||||
'raw': 'aa01bb'})
|
'raw': 'aa01bb'})
|
||||||
@@ -83,7 +94,7 @@ class TestValidation(unittest.TestCase):
|
|||||||
self.assertEqual(respond['raw'], 'AA01BB')
|
self.assertEqual(respond['raw'], 'AA01BB')
|
||||||
tr = T.build_tr(4, 0x23, 0x81, 0x82, respond)
|
tr = T.build_tr(4, 0x23, 0x81, 0x82, respond)
|
||||||
self.assertEqual(tr.hex().upper(),
|
self.assertEqual(tr.hex().upper(),
|
||||||
'8103042300' + '82028182' + 'AA01BB' + '0D0600' + '68656C6C6F'
|
'8103042300' + '82028182' + 'AA01BB' + '8D0600' + '68656C6C6F'
|
||||||
+ '83020000')
|
+ '83020000')
|
||||||
|
|
||||||
|
|
||||||
@@ -314,6 +325,21 @@ class TestRunnerDialogue(RunnerTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestRunnerActions(RunnerTestCase):
|
class TestRunnerActions(RunnerTestCase):
|
||||||
|
def test_script_menu_select_mirrors_menu_active(self):
|
||||||
|
# 9000 -> the menu dialogue is over
|
||||||
|
server = FakeServer(FakeScc().push('80C2', '', '9000'))
|
||||||
|
self.run_script(server, [{'type': 'action', 'kind': 'menu-select', 'params': {'item_id': 1}}])
|
||||||
|
self.assertFalse(server.menu_active)
|
||||||
|
# 91XX -> a menu dialogue is pending (drained at the end)
|
||||||
|
scc = FakeScc()
|
||||||
|
scc.push('80C2', '', '9103')
|
||||||
|
scc.push('8012', SELECT_ITEM_CMD, '9000')
|
||||||
|
scc.push('8014', '', '9000')
|
||||||
|
server = FakeServer(scc)
|
||||||
|
self.run_script(server, [{'type': 'action', 'kind': 'menu-select',
|
||||||
|
'params': {'item_id': 1}, 'check': {'sw': '91??'}}])
|
||||||
|
self.assertTrue(server.menu_active)
|
||||||
|
|
||||||
def test_scp80_uses_the_preset_and_advances_the_counter(self):
|
def test_scp80_uses_the_preset_and_advances_the_counter(self):
|
||||||
scc = FakeScc()
|
scc = FakeScc()
|
||||||
server = FakeServer(scc)
|
server = FakeServer(scc)
|
||||||
@@ -341,6 +367,26 @@ class TestRunnerActions(RunnerTestCase):
|
|||||||
self.assertEqual(build.call_args[0][5], '0000000A')
|
self.assertEqual(build.call_args[0][5], '0000000A')
|
||||||
self.assertFalse(send.call_args.kwargs.get('handle_proactive', True))
|
self.assertFalse(send.call_args.kwargs.get('handle_proactive', True))
|
||||||
|
|
||||||
|
def test_scp80_source_selects_between_apdu_and_packet(self):
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80',
|
||||||
|
'params': {'source': 'apdu', 'apdu': '80E2900000', 'sp': 'AABB'}},
|
||||||
|
]}, _resolver)
|
||||||
|
params = script['steps'][0]['params']
|
||||||
|
self.assertIn('apdu', params)
|
||||||
|
self.assertNotIn('sp', params)
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'source': 'sp', 'sp': 'AABB', 'apdu': '80E2'}},
|
||||||
|
]}, _resolver)
|
||||||
|
self.assertIn('sp', script['steps'][0]['params'])
|
||||||
|
self.assertNotIn('apdu', script['steps'][0]['params'])
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'source': 'sp', 'apdu': '80E2'}}]}, _resolver)
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'source': 'nope', 'apdu': '80E2'}}]}, _resolver)
|
||||||
|
|
||||||
def test_scp80_step_overrides_tar_and_spi_only(self):
|
def test_scp80_step_overrides_tar_and_spi_only(self):
|
||||||
scc = FakeScc()
|
scc = FakeScc()
|
||||||
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16,
|
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16,
|
||||||
|
|||||||
Reference in New Issue
Block a user