diff --git a/docs/api.md b/docs/api.md index c42459c..9ea387f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -394,9 +394,10 @@ suspended; only `/api/test/*`, `/api/status`, `/api/poll-status`, **Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`), `menu-select` (`item_id` 1-255), `file-write` (`path`, `data`, `mode` `auto`/`binary`/`record`, `record`), `file-read` (same, verifies `check.data`), -`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, optional -`tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always come from the -`preset`, which must match the equipped card and be complete) or `status` +`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by +the optional `source` field when both are present; optional `tar`/`spi1`/`spi2` +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 mask `91??`, i.e. poll until the card announces a command). diff --git a/frontend/index.html b/frontend/index.html index f691359..0860fe9 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.12'; +const SIMPLE_VERSION = '3.5.13'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== 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 : ''); 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 === '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-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)' : ''); @@ -6743,10 +6743,6 @@ function testFormSelect(id, value, options, onchange) { return html + ''; } -function testCheckModes(value) { - return [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}, {v: 'contains', l: t('Contains')}]; -} - function testStepRender() { const modal = document.getElementById('test-step-modal'); const title = document.getElementById('test-step-title'); @@ -6776,9 +6772,10 @@ function testStepRender() { } else if (step.kind === 'apdu') { html += testFormRow('APDU (hex)', testFormInput('test-f-apdu', p.apdu, '00A4040008A000000151000000')); } 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()')); - 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')); 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')); @@ -6791,7 +6788,7 @@ function testStepRender() { const sw = c.sw || {}; html += testFormRow('Expected SW', '
' + 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??') + '
'); + + testFormInput('test-f-sw', sw.value === undefined ? '' : sw.value, t('default: 9000 (91?? when polling)')) + ''); const cd = c.data || {}; html += testFormRow('Expected data (hex, optional)', '
' + 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') { html += testFormInput('test-check-id-' + i, c.id, t('id')); 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') { 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??'); @@ -6946,9 +6944,16 @@ function testStepCollect() { } else if (kind === 'apdu') { p.apdu = hex('test-f-apdu'); } else if (kind === 'scp80') { - const src = val('test-f-src') || 'apdu'; - if (src === 'sp') p.sp = hex('test-f-sp'); - else p.apdu = hex('test-f-apdu'); + // 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'); + 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; }); } 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') { const p = step.params || {}; 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 === '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'); @@ -7164,10 +7172,19 @@ async function testPollRun() { function testWriteBackCounter() { const st = _testRunState; - if (!st || st.running || !st.scp80_counter || _testLastPresetIdx < 0) return; - const preset = cards[_testLastPresetIdx]; - if (!preset) return; + if (!st || st.running || !st.scp80_counter) return; 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; preset.cntr = next; cardsSave(); @@ -7181,6 +7198,7 @@ async function testInit() { const st = await pysimFetch('/api/test/status'); testRenderRun(st); if (st && st.running) testStartRunTimer(); + else testWriteBackCounter(); } catch (e) { /* server optional */ } } @@ -16324,6 +16342,7 @@ const LANG_RU = { 'Interval (ms, when polling)': 'Интервал (мс, при опросе)', 'attempts must be at least 1': 'число попыток должно быть ≥ 1', 'Expected SW': 'Ожидаемое SW', + 'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)', 'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)', 'PoR check': 'Проверка PoR', 'Any': 'Любой', diff --git a/frontend/sw.js b/frontend/sw.js index c3178a5..b0e84f9 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v265'; +const CACHE = 'simple-v266'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js index 0540bd7..8c05b7d 100644 --- a/frontend/tests/testscripts.test.js +++ b/frontend/tests/testscripts.test.js @@ -36,6 +36,21 @@ eval(extractFunc(html, 'testCheckSummary')); eval(extractFunc(html, 'testStepSummary')); eval(extractFunc(html, 'testCommandOptions')); 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', () => { 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')); }); +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, /