v3.15.0: test-script fixes for applet testing - preset id, kvn, PoR and file-list checks

Three gaps blocked SCP80 applet testing from the PWA:

- testRunPreset() still marshalled the pre-v3.8 flat preset fields
  (kic/kid/keys/counter/tar/spi1/spi2, all empty on a stored preset), so
  every SCP80 run failed with "SCP80 preset is incomplete: no keyset is
  defined".  The PWA sends the matched preset's id now and the server
  resolves it from the store (_test_run_preset_from_body; the inline
  `preset` body stays for API callers), so a run always uses the stored
  keysets, TARs and counters.  Regression tests on both sides.
- testscript._normalise_params dropped the scp80 step's `kvn` (the editor
  exposed it and the runner and validation consumed it, so scripts
  silently used the preset's first keyset).  kvn is validated (1-15) and
  passed through; the client-side problem check bounds it too.
- The PoR delivered as a SEND SHORT MESSAGE (PoR-in-submit, SPI2 bit 0x20)
  is decoded for the expect step that fetches it: the new `por` content
  check asserts the response status / R-APDU SW / response data, decoded
  with the preceding scp80 step's context (concatenated SMS accumulate
  across expectations).  The inline transport stays on the action
  (check.por / check.data).
- The new `files` content check asserts the File List of a REFRESH
  (TS 102 223 8.18, tag 12/92) - e.g. qualifier 00 = NAA initialization
  with full file change notification - as an order-insensitive set of FID
  paths.

The step editor exposes both checks; help EN/RU, docs/api.md and the new
authoring reference docs/test-scripts.md document the format.  Tests: 803
frontend + 680 Python; sw cache simple-v378.
This commit is contained in:
2026-10-04 17:09:06 +03:00
parent 3c5882d16d
commit abc9ea2751
11 changed files with 699 additions and 33 deletions
+47 -19
View File
@@ -1877,7 +1877,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.14.2';
const SIMPLE_VERSION = '3.15.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -6946,6 +6946,10 @@ function testScriptProblem(script) {
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 (p.kvn !== undefined && String(p.kvn).trim() !== '') {
const kvn = parseInt(String(p.kvn).trim(), 10);
if (!(kvn >= 1 && kvn <= 15)) return at + t('keyset number must be 1..15');
}
}
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');
@@ -6968,6 +6972,14 @@ function testCheckSummary(c) {
return s;
}
if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || '');
if (c.kind === 'por') {
const parts = [];
if (c.status) parts.push('status ' + c.status);
if (c.sw) parts.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value);
if (c.data) parts.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value);
return 'por ' + parts.join(' ');
}
if (c.kind === 'files') return 'files ' + (c.files || []).join(' ');
return '';
}
@@ -7330,7 +7342,8 @@ function testRenderChecks() {
_testEditChecks.forEach((c, i) => {
html += '<div class="flex items-start gap-2 mb-1">'
+ testFormSelect('test-check-kind-' + i, c.kind || 'text', [
{v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')}], 'testCheckKindChanged()');
{v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')},
{v: 'por', l: t('PoR')}, {v: 'files', l: t('Files')}], 'testCheckKindChanged()');
if (c.kind === 'item') {
html += testFormInput('test-check-id-' + i, c.id, t('id'));
html += testFormInput('test-check-text-' + i, c.text, t('text'));
@@ -7339,6 +7352,12 @@ function testRenderChecks() {
} 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??');
} else if (c.kind === 'por') {
html += testFormInput('test-check-porstatus-' + i, c.status, 'por_ok');
html += testFormInput('test-check-por-sw-' + i, c.sw && c.sw.value, '6A88');
html += testFormInput('test-check-por-data-' + i, c.data && c.data.value, 'AA??');
} else if (c.kind === 'files') {
html += testFormInput('test-check-files-' + i, (c.files || []).join(', '), '3F007F106F3A');
} else {
html += testFormSelect('test-check-mode-' + i, c.mode || 'contains', [{v: 'contains', l: t('Contains')}, {v: 'exact', l: t('Exact')}]);
html += testFormInput('test-check-value-' + i, c.value, t('text'));
@@ -7383,6 +7402,16 @@ function testChecksCollect() {
} else if (kind === 'raw') {
row.mode = val('test-check-mode-' + i) || 'mask';
row.value = val('test-check-value-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
} else if (kind === 'por') {
const status = val('test-check-porstatus-' + i);
if (status) row.status = status;
const sw = val('test-check-por-sw-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
if (sw) row.sw = {mode: sw.includes('?') ? 'mask' : 'exact', value: sw};
const data = val('test-check-por-data-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
if (data) row.data = {mode: data.includes('?') ? 'mask' : 'exact', value: data};
} else if (kind === 'files') {
row.files = val('test-check-files-' + i).split(/[,\s]+/)
.map(s => s.replace(/[^0-9a-fA-F]/g, '').toUpperCase()).filter(Boolean);
} else {
row.mode = val('test-check-mode-' + i) || 'contains';
row.value = val('test-check-value-' + i);
@@ -7450,6 +7479,8 @@ function testStepCollect() {
step.checks = JSON.parse(JSON.stringify(_testEditChecks)).filter(c => {
if (c.kind === 'text') return !!(c.value || '').trim();
if (c.kind === 'item') return c.id || (c.text || '').trim();
if (c.kind === 'por') return !!(c.status || c.sw || c.data);
if (c.kind === 'files') return !!(c.files && c.files.length);
return !!(c.value || '').trim();
});
const r = {result: val('test-f-result') || 'ok'};
@@ -7567,34 +7598,28 @@ function testRunStepHtml(step) {
function testRunPreset() {
const preset = cardsMatchedPreset();
if (!preset) return {error: t('No card preset matches the equipped card - SCP80 steps need one')};
if (!cardsScp80Complete(preset)) return {error: t('The matched card preset has incomplete SCP80 data (KIc/KID, SPI1/SPI2, counter, keys)')};
if (!cardsScp80Complete(preset)) return {error: t('The matched card preset has incomplete SCP80 data (a complete keyset and the role TARs with their MSLs)')};
_testLastPresetIdx = cards.indexOf(preset);
return {preset: {
name: preset.name || '',
iccid: preset.iccid || '',
kic: String(preset.kic || '').toUpperCase(),
kid: String(preset.kid || '').toUpperCase(),
kicKey: String(preset.kicKey || '').replace(/\s/g, '').toUpperCase(),
kidKey: String(preset.kidKey || '').replace(/\s/g, '').toUpperCase(),
counter: String(preset.cntr || '').replace(/\s/g, '').toUpperCase(),
tar: String(preset.tar || '').replace(/\s/g, '').toUpperCase(),
spi1: String(preset.spi1 || '').replace(/\s/g, '').toUpperCase(),
spi2: String(preset.spi2 || '').replace(/\s/g, '').toUpperCase(),
}};
// The run names the preset; the server resolves it from the store, so the
// run uses the stored keysets, TARs and counters (the flat preset fields
// this used to send no longer exist - the run failed with "no keyset").
return {preset_id: preset.id};
}
async function testRunStart() {
const cur = testCurrent();
const problem = testScriptProblem(cur);
if (problem) { testRunMessage(problem, 'error'); return; }
let preset = null;
let presetId = null;
if (cur.steps.some(s => s.type === 'action' && s.kind === 'scp80')) {
const found = testRunPreset();
if (found.error) { testRunMessage(found.error, 'error'); return; }
preset = found.preset;
presetId = found.preset_id;
}
try {
const resp = await pysimFetch('/api/test/run', {script: cur, preset: preset});
const body = {script: cur};
if (presetId) body.preset_id = presetId;
const resp = await pysimFetch('/api/test/run', body);
if (resp.error) { testRunMessage(resp.error, 'error'); return; }
testRenderRun(resp);
testStartRunTimer();
@@ -20904,6 +20929,9 @@ const LANG_RU = {
'Contains': 'Содержит',
'Item': 'Пункт',
'Raw': 'Raw',
'PoR': 'PoR',
'Files': 'Файлы',
'keyset number must be 1..15': 'номер keyset должен быть 1..15',
'Test script running:': 'Выполняется тест-скрипт:',
'Test script running': 'Выполняется тест-скрипт',
'Preset': 'Пресет',
@@ -20913,7 +20941,7 @@ const LANG_RU = {
'running': 'выполняется',
'Received': 'Получено',
'No card preset matches the equipped card - SCP80 steps need one': 'Ни один пресет карты не соответствует подключённой карте — для шагов SCP80 он нужен',
'The matched card preset has incomplete SCP80 data (KIc/KID, SPI1/SPI2, counter, keys)': 'У подобранного пресета неполные данные SCP80 (KIc/KID, SPI1/SPI2, счётчик, ключи)',
'The matched card preset has incomplete SCP80 data (a complete keyset and the role TARs with their MSLs)': 'У подобранного пресета неполные данные SCP80 (полный keyset и ролевые TAR с их MSL)',
'SCP80 counter saved to the preset:': 'Счётчик SCP80 сохранён в пресет:',
'STK menu': 'Меню STK',
'No menu set by the card': 'Меню не задано картой',