diff --git a/docs/api.md b/docs/api.md
index a444ec4..6d22280 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -653,13 +653,19 @@ suspended; only `/api/test/*`, `/api/status`, `/api/poll-status`,
{"role": "usimRfm", "tar": "B00001", "msl": "16"}]}}
```
+The PWA sends `preset_id` (the stored preset's id) instead of `preset`; the
+server resolves it from the preset store, so a run always uses the stored
+keysets, TARs and counters. The inline `preset` form stays for external
+callers.
+
**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`, 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`
+the optional `source` field when both are present; optional `kvn` (keyset
+number 1-15), `tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always
+come from the resolved 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).
@@ -672,7 +678,12 @@ previous step (`91XX`); they never poll - a `9000` response means no command
and is an error (TS 102 221 7.4.2.1 / TS 102 223 6.3; add a `status` action
if the card delivers on poll). `command` is a name or type code; `checks`
may be `text` (contains/exact), `item` (`id`/`text` for SELECT ITEM / SET UP
-MENU) or `raw` (mask); `respond` is the TERMINAL RESPONSE (`result` name or
+MENU), `raw` (mask), `por` (a SEND SHORT MESSAGE carrying the PoR of the
+preceding `scp80` step - PoR-in-submit, SPI2 bit `20`: `status` (the response
+status name, `ok` = `por_ok`), `sw` (the R-APDU status word, exact/mask) and
+`data` (the R-APDU response data, exact/mask)) or `files` (the File List of a
+REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched
+order-insensitively); `respond` is the TERMINAL RESPONSE (`result` name or
value, `item_id`, `text`+`dcs`, raw TLVs).
The response is the initial state (`running: true`), the final counter
diff --git a/docs/test-scripts.md b/docs/test-scripts.md
new file mode 100644
index 0000000..10ff9f9
--- /dev/null
+++ b/docs/test-scripts.md
@@ -0,0 +1,221 @@
+# Test script authoring guide
+
+A **test script** is a JSON document that drives a deterministic dialogue with
+the card over the local server: ordered **actions** (each sends something and
+checks the response) and **expectations** (each fetches the proactive command
+the card announced, checks it, and sends the scripted TERMINAL RESPONSE).
+
+Scripts are imported into the PWA (Simulator → Test script → Import) and run
+with **Run** against the equipped card, or POSTed directly to
+`/api/test/run` (see `docs/api.md`). While a run is active the card belongs to
+the script.
+
+## Document shape
+
+```json
+{"name": "applet RFM update",
+ "steps": [
+ {"type": "action", "kind": "scp80", "params": {…}, "check": {…}, "label": "optional"},
+ {"type": "expect", "command": "REFRESH", "qualifier": "00",
+ "checks": [{"kind": "files", "value": "3F007F206F07"}],
+ "respond": {"result": "ok"}}
+ ]}
+```
+
+Every step may carry `on_fail`: `"error"` (default - stops the run) or
+`"warning"` (continues, the report shows ⚠). Each entry of `checks` may
+override it with its own `on_fail`.
+
+## Presets: where the keys, TARs and counters come from
+
+An SCP80 action never carries key material. The PWA sends the **stored
+preset's id** with the run; the server resolves it and takes:
+
+- the keyset - the step's `kvn` (1–15) or the preset's first keyset,
+- the keys and the counter of that keyset,
+- the TAR table with each TAR's **MSL** (Minimum SPI1).
+
+So before a run the matched preset (ICCID) must contain the keyset and, for
+every TAR the script addresses, either a TAR entry with its MSL or an explicit
+`spi1` in the step. Counter handling: the run starts from the keyset's stored
+counter and persists each accepted packet's next value, so consecutive runs
+continue correctly.
+
+## Actions
+
+```json
+{"type": "action", "kind": "…", "params": {…}, "check": {…}}
+```
+
+| kind | params | notes |
+|---|---|---|
+| `envelope` | `event` 0–255, `data` hex (optional) | ENVELOPE(Event Download) |
+| `menu-select` | `item_id` 1–255 | ENVELOPE(Menu Selection) |
+| `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD |
+| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` |
+| `apdu` | `apdu` hex | raw transport, no auto-handler |
+| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2` | secured packet to the TAR |
+| `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) |
+
+`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
+or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
+
+### `check` on actions
+
+```json
+"check": {"sw": {"mode": "mask", "value": "91??"},
+ "data": {"mode": "exact", "value": "0271…"},
+ "por": "ok"}
+```
+
+- `sw` - default `{"mode": "exact", "value": "9000"}` (a `status` action with
+ `attempts > 1` defaults to the `91??` mask).
+- `data` - the response data (for SCP80: the inline PoR packet).
+- `por` - `"any"` (default, no check), `"ok"` (`response_status == por_ok`),
+ `"none"` (no PoR expected).
+- A plain hex string is exact; a value containing `?` is a mask. **A mask
+ must have exactly the same length as the actual value** (`?` is a per-nibble
+ wildcard) - e.g. `91??` matches `9102`, `027100????` matches a 4-byte
+ prefix.
+
+## Expectations
+
+```json
+{"type": "expect", "command": "SEND SHORT MESSAGE",
+ "qualifier": "00",
+ "checks": [{"kind": "por", "status": "por_ok", "sw": "9000"}],
+ "respond": {"result": "ok"}}
+```
+
+The card must have announced a command (`91XX`) in the previous step - the
+runner never polls by itself. `command` is a name (as shown in the proactive
+log) or a hex type; `ANY`/`*` skips the type check. `qualifier` is the
+command-details byte 3 (one byte, exact/mask).
+
+### Content checks
+
+| kind | fields | checks |
+|---|---|---|
+| `text` | `mode` contains/exact, `value`, `case_sensitive` | alpha identifier / text |
+| `item` | `id`, `text`, `mode`, `case_sensitive` | items of SELECT ITEM / SET UP MENU |
+| `raw` | `mode` exact/mask, `value` | the whole fetched command (hex, exact length needed) |
+| `por` | `status`, `sw`, `data` | the PoR carried by a **SEND SHORT MESSAGE** (see below) |
+| `files` | `files` (list of path hex strings) or a comma/space separated string | the **File List** of a REFRESH, TS 102 223 §8.18; matched order-insensitively |
+
+`por` needs an SCP80 step **before** the expectation: the TPDU of the SEND
+SHORT MESSAGE is decoded with that step's keyset, SPI and counter. `status`
+is the decoded response status name (`por_ok`, `cntr_low`,
+`rc_cc_ds_failed`, …; `ok` is accepted for `por_ok`); `sw` is the R-APDU
+status word and `data` the R-APDU response data (exact/mask) - both come from
+the decoded response (scripting `AB`/`AF`/compact forms). A PoR split over
+several SMS parts is accumulated across SEND SHORT MESSAGE expectations: put
+the `por` check on the step that completes the sequence (intermediate steps
+should use `raw` or no PoR check).
+
+### `respond`
+
+The TERMINAL RESPONSE for the fetched command: `result` (name or hex -
+`ok`, `partial`, `missing`, `refused`, `not_understood`, `modified`, `cancel`,
+`back`, `timeout`, `no_response`), `item_id`, `text`+`dcs`, `raw` (extra
+COMPREHENSION-TLVs before the result).
+
+## The two PoR transports (SPI2)
+
+1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the
+ response data. Verify on the action: `"por": "ok"` and/or
+ `"data": {"mode": "mask", "value": "…"}`.
+2. **PoR-in-submit** (SPI2 bit `20`, e.g. `spi2: "21"`) - the card announces
+ a **SEND SHORT MESSAGE** (`91XX`); the PoR is inside its SMS TPDU:
+
+```json
+{"type": "action", "kind": "scp80",
+ "params": {"apdu": "…", "tar": "B00001", "spi2": "21"},
+ "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}},
+{"type": "expect", "command": "SEND SHORT MESSAGE",
+ "checks": [{"kind": "por", "status": "por_ok", "sw": "9000"}],
+ "respond": {"result": "ok"}}
+```
+
+If the card announces the SEND SHORT MESSAGE only on a later STATUS poll,
+insert a `status` action (`attempts` > 1) before the expectation.
+
+## "No actions" after a trigger
+
+An exact SW check is the assertion: `"check": {"sw": "9000"}` fails if the
+card answers `91XX` (a command was announced); the runner also errors on a
+pending command the next step does not expect.
+
+## Worked examples
+
+### RFM update → file change → REFRESH (NAA init + full FCN)
+
+```json
+{"name": "applet RFM update",
+ "steps": [
+ {"type": "action", "kind": "scp80",
+ "params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "01"},
+ "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
+ "label": "RFM update"},
+ {"type": "expect", "command": "REFRESH", "qualifier": "00",
+ "checks": [{"kind": "files", "value": "3F007F206F07"}],
+ "respond": {"result": "ok"}},
+ {"type": "action", "kind": "file-read", "params": {"path": "MF/7F20/6F07"},
+ "check": {"data": {"mode": "mask", "value": "AA??CC"}}}
+ ]}
+```
+
+`qualifier` `00` is "NAA Initialization and Full File Change Notification";
+the `files` check asserts the exact set of changed files the applet reports.
+For this mode the terminal must **not** reset the card
+(TS 102 223 §6.4.7), so the run continues - the following `file-read`
+verifies the updated content.
+
+### Incoming data → particular PoR, no other actions
+
+Inline PoR:
+
+```json
+{"name": "applet data (inline PoR)",
+ "steps": [
+ {"type": "action", "kind": "scp80",
+ "params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "01"},
+ "check": {"sw": "9000", "por": "ok",
+ "data": {"mode": "mask", "value": "027100????0102"}}}
+ ]}
+```
+
+PoR-in-submit (with the transport asserted by the expectation):
+
+```json
+{"name": "applet data (SEND SM PoR)",
+ "steps": [
+ {"type": "action", "kind": "scp80",
+ "params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "21"},
+ "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}},
+ {"type": "expect", "command": "SEND SHORT MESSAGE",
+ "checks": [{"kind": "por", "status": "por_ok"}],
+ "respond": {"result": "ok"}}
+ ]}
+```
+
+## Run errors worth knowing
+
+- `SCP80 preset is incomplete: …` - the matched preset has no complete
+ keyset; `step N: keyset K is not defined in the preset` - the step's `kvn`
+ is outside the preset's keysets.
+- `no MSL for TAR … - set it in the Cards tab or pass spi1` - the step's TAR
+ has no entry in the preset and the step carries no `spi1`.
+- `no proactive command pending …` - an expectation ran without a `91XX` from
+ the previous step (add a `status` action when the card delivers on poll).
+- `card verdict: no PoR (the card sent none for SPI2 …)` - an SCP80 operation
+ expected a PoR that never arrived (check SPI2 and the transport).
+
+## Known limitations
+
+- The runner executes one command at a time and never polls by itself; a
+ proactive command that arrives only on a STATUS poll needs an explicit
+ `status` action.
+- A `por` check only decodes SEND SHORT MESSAGE commands; the inline transport
+ is checked on the action (`por`/`data`).
+- `files` matches the File List as a set (order-insensitive); duplicate paths
+ are kept.
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 2eeafa9..ac154ea 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -563,8 +563,8 @@
8.9 Тест-скрипт
Тест-скрипт — упорядоченный список действий и ожиданий проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия Очистить отчёт. Скрипты хранятся в localStorage (simple_tests) и экспортируются/импортируются как JSON.
- - Действия — Envelope (Event Download: тип события + данные), Menu Selection (item id 1–255), UPDATE/READ файла (путь + режим; чтение проверяет данные файла), сырой APDU (без авто-обработчика), SCP80 (C-APDU или готовый защищённый пакет, опциональные переопределения TAR/SPI1/SPI2; KIc/KID и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты, а счётчик записывается обратно после запуска) и STATUS (при попытках > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой
? — 91??) и проверка PoR для SCP80 (любой / без PoR / por_ok).
- - Ожидания — карта должна объявить проактивную команду на предыдущем шаге (SW
91XX, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); 9000 означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое (текст / пункт / raw), а TERMINAL RESPONSE (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.
+ - Действия — Envelope (Event Download: тип события + данные), Menu Selection (item id 1–255), UPDATE/READ файла (путь + режим; чтение проверяет данные файла), сырой APDU (без авто-обработчика), SCP80 (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и STATUS (при попытках > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой
? — 91??) и проверка PoR для SCP80 (любой / без PoR / por_ok).
+ - Ожидания — карта должна объявить проактивную команду на предыдущем шаге (SW
91XX, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); 9000 означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка PoR (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 20: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка Files (File List команды REFRESH, например квалификатор 00 = NAA initialization with full file change notification). TERMINAL RESPONSE (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.
- Уровни ошибок — несовпадение помечает шаг как Ошибка (скрипт останавливается) или Предупреждение (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.
- Шаблоны — Empty, Обзор меню STK, Апплет через SCP80; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.
diff --git a/frontend/help.html b/frontend/help.html
index eb3591d..2db359c 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -562,8 +562,8 @@
8.9 Test script
A test script is an ordered list of actions and proactive-command expectations executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until Clear report. Scripts live in localStorage (simple_tests) and export/import as JSON.
- - Actions — Envelope (Event Download: event type + data), Menu Selection (item id 1–255), UPDATE/READ file (path + mode; reads verify the file data), raw APDU (no auto-handler), SCP80 (C-APDU or a pre-built secured packet, optional TAR/SPI1/SPI2 overrides; KIc/KID and the counter come from the card preset matched by the equipped card's ICCID, and the counter is written back to it after the run) and STATUS (with attempts > 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with
? nibble wildcards — 91??) plus a PoR check for SCP80 (any / no PoR / por_ok).
- - Expectations — the card must have announced a proactive command in the previous step (SW
91XX, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a 9000 means “no command” and is an error — add a STATUS action when the applet delivers on poll. The command type, qualifier and contents (text / item / raw checks) are verified, and the scripted TERMINAL RESPONSE (result, item identifier, text, extra TLVs) is part of the instruction.
+ - Actions — Envelope (Event Download: event type + data), Menu Selection (item id 1–255), UPDATE/READ file (path + mode; reads verify the file data), raw APDU (no auto-handler), SCP80 (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — and the counter is written back to it after the run) and STATUS (with attempts > 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with
? nibble wildcards — 91??) plus a PoR check for SCP80 (any / no PoR / por_ok).
+ - Expectations — the card must have announced a proactive command in the previous step (SW
91XX, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a 9000 means “no command” and is an error — add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, a PoR check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit 20: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a Files check (the File List of a REFRESH, e.g. qualifier 00 = NAA initialization with full file change notification). The scripted TERMINAL RESPONSE (result, item identifier, text, extra TLVs) is part of the instruction.
- Fail levels — a mismatch flags the step as Error (terminates the script) or Warning (continues); each check is reported with expected vs actual values.
- Templates — Empty, STK menu browsing, Applet via SCP80; the step editor is form-based and individual scripts export/import as JSON.
diff --git a/frontend/index.html b/frontend/index.html
index 37d8c3f..36e1f75 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -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 += ''
+ 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': 'Меню не задано картой',
diff --git a/frontend/sw.js b/frontend/sw.js
index 3071e92..d3fe118 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v377';
+const CACHE = 'simple-v378';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js
index bb6fc7e..ff5b595 100644
--- a/frontend/tests/testscripts.test.js
+++ b/frontend/tests/testscripts.test.js
@@ -44,6 +44,7 @@ eval(extractFunc(html, 'testStepRender'));
eval(extractFunc(html, 'testRenderChecks'));
eval(extractFunc(html, 'testStepCollect'));
eval(extractFunc(html, 'testWriteBackCounter'));
+eval(extractFunc(html, 'testRunPreset'));
eval(extractFunc(html, 'testChecksCollect'));
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
+ ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;'
@@ -266,3 +267,85 @@ test('the Simulator hosts the Test script pill and its wiring', () => {
assert.match(html, /\/api\/test\/status/);
assert.match(html, /id="test-step-modal"/);
});
+
+test('the run sends the stored preset id, not the removed flat fields', () => {
+ const preset = { id: 'p1', name: 'Card 1',
+ keysets: [{ kic: '15', kid: '15', kicKey: 'AA'.repeat(16),
+ kidKey: 'BB'.repeat(16), cntr: '0000000A' }],
+ tars: [{ role: 'isd', tar: '000000', msl: '16' }] };
+ globalThis.cards = [preset];
+ globalThis.cardsMatchedPreset = () => preset;
+ globalThis.cardsScp80Complete = () => true;
+ const found = testRunPreset();
+ // the preset body used to carry flat kic/kid/... fields that no longer
+ // exist on a stored preset - the server resolved an empty keyset and
+ // refused every SCP80 run
+ assert.deepStrictEqual(found, { preset_id: 'p1' });
+ assert.strictEqual(_testLastPresetIdx, 0);
+});
+
+test('the run refuses without a matched or complete preset', () => {
+ globalThis.cards = [];
+ globalThis.cardsMatchedPreset = () => null;
+ assert.match(testRunPreset().error, /No card preset matches/);
+ const preset = { id: 'p2' };
+ globalThis.cards = [preset];
+ globalThis.cardsMatchedPreset = () => preset;
+ globalThis.cardsScp80Complete = () => false;
+ assert.match(testRunPreset().error, /incomplete SCP80/);
+});
+
+test('the client-side problem check bounds the keyset number', () => {
+ assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
+ params: { apdu: '80E2', kvn: 2 } }] }), '');
+ assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
+ params: { apdu: '80E2', kvn: 16 } }] }), /keyset number/);
+ assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
+ params: { apdu: '80E2', kvn: 0 } }] }), /keyset number/);
+});
+
+test('the PoR and file-list checks render and collect', () => {
+ const els = { 'test-checks': {} };
+ globalThis.document = { getElementById: id => els[id] || null };
+ _testEditChecks = [{ kind: 'por', status: 'por_ok',
+ sw: { mode: 'mask', value: '6A8?' }, on_fail: 'error' }];
+ testRenderChecks();
+ const rendered = els['test-checks'].innerHTML;
+ assert.ok(rendered.includes('value="por" selected'), rendered);
+ assert.match(rendered, /id="test-check-porstatus-0"/);
+ assert.match(rendered, /id="test-check-por-sw-0"/);
+
+ const values = {
+ 'test-check-kind-0': 'por',
+ 'test-check-porstatus-0': 'por_ok',
+ 'test-check-por-sw-0': '6A8?',
+ 'test-check-por-data-0': 'aabb',
+ 'test-check-fail-0': 'warning',
+ };
+ globalThis.document = { getElementById: id => (id in values ? { value: values[id] } : null) };
+ testChecksCollect();
+ assert.deepStrictEqual(_testEditChecks[0], {
+ kind: 'por', on_fail: 'warning', status: 'por_ok',
+ sw: { mode: 'mask', value: '6A8?' },
+ data: { mode: 'exact', value: 'AABB' },
+ });
+
+ _testEditChecks = [{ kind: 'files', files: ['3F007F106F3A'], on_fail: 'error' }];
+ globalThis.document = { getElementById: id => els[id] || null };
+ testRenderChecks();
+ assert.match(els['test-checks'].innerHTML, /id="test-check-files-0"/);
+ const values2 = { 'test-check-kind-0': 'files',
+ 'test-check-files-0': '3F007F106F3A, 3f002fe2', 'test-check-fail-0': 'error' };
+ globalThis.document = { getElementById: id => (id in values2 ? { value: values2[id] } : null) };
+ testChecksCollect();
+ assert.deepStrictEqual(_testEditChecks[0],
+ { kind: 'files', on_fail: 'error', files: ['3F007F106F3A', '3F002FE2'] });
+});
+
+test('the check summary names the PoR and file-list checks', () => {
+ assert.strictEqual(testCheckSummary({ kind: 'por', status: 'por_ok',
+ sw: { mode: 'mask', value: '6A8?' }, data: { mode: 'exact', value: 'AABB' } }),
+ 'por status por_ok SW ~6A8? data AABB');
+ assert.strictEqual(testCheckSummary({ kind: 'files', files: ['3F002FE2'] }),
+ 'files 3F002FE2');
+});
diff --git a/pyproject.toml b/pyproject.toml
index 54153cc..4637cf6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.14.2"
+version = "3.15.0"
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 7d12a26..f7086d4 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.14.2'
+VERSION = '3.15.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5870,6 +5870,12 @@ def _test_run_scp80(server, step, ctx):
counters[kvn] = (keyset or {}).get('cntr') or '00000000'
counter = counters.get(kvn) if kvn else (ctx.get('counter') or '00000000')
counter = counter or '00000000'
+ # The PoR of this exchange may arrive as a SEND SHORT MESSAGE (PoR-in-
+ # submit); the expect step decodes it with the parameters the packet was
+ # built from, so remember them.
+ ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
+ 'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key,
+ 'tar': tar}
if p.get('sp'):
sp_hex = p['sp']
source = 'sp'
@@ -5943,7 +5949,82 @@ def _test_run_action(server, step, ctx):
return result, pending
-def _test_run_expect(server, step, pending):
+def _test_run_preset_from_body(server, body):
+ """The preset of a run: the inline `preset` body (external API callers,
+ docs/api.md) or the stored preset named by `preset_id` - the PWA sends the
+ id, so the run always uses the store's authoritative keys/TARs/counters.
+ Returns ``(preset, error)``; error is None on success."""
+ preset = body.get('preset') or {}
+ if preset:
+ return preset, None
+ pid = str(body.get('preset_id') or '').strip()
+ if not pid:
+ return {}, None
+ preset = _preset_by_id(server, pid)
+ if preset is None:
+ return None, 'card preset %s not found' % pid
+ return preset, None
+
+
+def _parse_file_list(raw):
+ """The File List (TS 102 223 8.18; tag 12/92 per TS 101 220) of a fetched
+ proactive command, as a list of path hex strings; [] when absent. The
+ value is the number of files followed by concatenated 2-byte FIDs - every
+ path starts with the MF ('3F'), which delimits the entries."""
+ try:
+ value = _cmd_tlv(httpota.proactive_tlvs(raw), 0x12)
+ except Exception:
+ return []
+ if not value:
+ return []
+ data = value[1:]
+ paths = []
+ cur = bytearray()
+ for i in range(0, len(data) - len(data) % 2, 2):
+ fid = data[i:i + 2]
+ if len(fid) < 2:
+ break
+ if fid[0] == 0x3F and cur:
+ paths.append(bytes(cur).hex().upper())
+ cur = bytearray()
+ cur += fid
+ if cur:
+ paths.append(bytes(cur).hex().upper())
+ return paths
+
+
+def _test_expect_por(ctx, raw):
+ """Decode the PoR carried by a fetched SEND SHORT MESSAGE command (the
+ PoR-in-submit transport, SPI2 bit 0x20, TS 102 225 5.1.1), using the
+ context of the scp80 step that triggered it. None when the command is
+ not a PoR or no scp80 step ran."""
+ last = (ctx or {}).get('last_scp80')
+ if not last:
+ return None
+ tpdu_hex = _find_sms_tpdu(raw)
+ if not tpdu_hex:
+ return None
+ try:
+ ref, total, num, payload = _parse_sms_concat(bytes.fromhex(tpdu_hex))
+ except Exception:
+ return None
+ if total is not None and num is not None and total > 1:
+ segs = ctx.setdefault('por_segments', [])
+ segs.append((ref, total, num, payload.hex()))
+ matching = sorted([s for s in segs if s[0] == ref], key=lambda s: s[2])
+ if len(matching) < total:
+ return None
+ ud_hex = ''.join(s[3] for s in matching)
+ else:
+ ud_hex = payload.hex()
+ if not ud_hex:
+ return None
+ por_hex = '027100' + ud_hex
+ return _decode_por(last['spi1'], last['spi2'], last['kic'], last['kid'],
+ last['counter'], last['kicKey'], last['kidKey'], por_hex)
+
+
+def _test_run_expect(server, step, pending, ctx=None):
scc = server.scc
if not pending:
raise testscript.ScriptError(
@@ -5957,6 +6038,10 @@ def _test_run_expect(server, step, pending):
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw)
log_entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN')
+ # A SEND SHORT MESSAGE carries the PoR when the packet asked for it in
+ # submit mode (SPI2 bit 0x20): decode it with the triggering scp80 step's
+ # context so the checks can assert the PoR contents.
+ por = _test_expect_por(ctx, raw) if cmd_type == 0x13 else None
checks = []
want_type = step['command'].get('type')
type_ok = want_type is None or cmd_type == want_type
@@ -6000,6 +6085,36 @@ def _test_run_expect(server, step, pending):
checks.append(_test_check_result('Raw',
testscript.match_value({'mode': c['mode'], 'value': c['value']}, actual),
c['value'], actual, c['on_fail']))
+ elif c['kind'] == 'por':
+ if por is None:
+ checks.append(_test_check_result(
+ 'PoR', False, 'a decoded PoR',
+ 'no PoR in this command (or no scp80 step before it)',
+ c['on_fail']))
+ else:
+ if 'status' in c:
+ actual = str(por.get('response_status') or '')
+ want = c['status']
+ ok = (actual.lower() == want.lower()
+ or (want.lower() == 'ok' and actual == 'por_ok'))
+ checks.append(_test_check_result('PoR status', ok, want,
+ actual or '(none)', c['on_fail']))
+ if 'sw' in c:
+ actual = str((por.get('decoded') or {}).get('last_status_word') or '')
+ checks.append(_test_check_result(
+ 'PoR SW', testscript.match_value(c['sw'], actual),
+ c['sw']['value'], actual or '(none)', c['on_fail']))
+ if 'data' in c:
+ actual = str((por.get('decoded') or {}).get('last_response_data') or '')
+ checks.append(_test_check_result(
+ 'PoR data', testscript.match_value(c['data'], actual),
+ c['data']['value'], actual or '(none)', c['on_fail']))
+ elif c['kind'] == 'files':
+ paths = _parse_file_list(raw)
+ want = c['files']
+ checks.append(_test_check_result(
+ 'Files', sorted(paths) == sorted(want), ', '.join(want),
+ ', '.join(paths) or '(none)', c['on_fail']))
tr = testscript.build_tr(cmd_num, cmd_type, dev_dst, dev_src, step['respond'])
tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex()))
tr_sw = tr_rv[1]
@@ -6007,6 +6122,7 @@ def _test_run_expect(server, step, pending):
status = testscript.combine_levels([c['level'] if not c['ok'] else 'ok' for c in checks])
result = {'status': status, 'sw': tr_sw, 'data': raw.hex().upper(),
'sent': 'TR ' + tr.hex().upper(), 'checks': checks,
+ 'por': por,
'command': {'type_hex': '%02X' % cmd_type, 'type_name': type_name,
'qualifier': '%02X' % cmd_qual if cmd_qual is not None else None,
'raw': raw.hex().upper()}}
@@ -6080,7 +6196,7 @@ def _test_run_execute(server, script, preset):
if step['type'] == 'action':
result, pending = _test_run_action(server, step, ctx)
else:
- result, pending = _test_run_expect(server, step, pending)
+ result, pending = _test_run_expect(server, step, pending, ctx)
_test_finish_entry(entry, result)
if entry['status'] == 'error':
# Error terminates the script; Warning and OK continue.
@@ -8048,7 +8164,12 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp, 400)
self._log_resp(resp)
return
- preset = body.get('preset') or {}
+ preset, preset_err = _test_run_preset_from_body(self.server, body)
+ if preset_err:
+ resp = {'error': preset_err}
+ self._send_json(resp, 400)
+ self._log_resp(resp)
+ return
needs_scp80 = any(s['type'] == 'action' and s['kind'] == 'scp80'
for s in script['steps'])
err = _test_preset_error(script, preset) if needs_scp80 else None
diff --git a/pysim_simple_server/testscript.py b/pysim_simple_server/testscript.py
index 3742535..8ed409b 100644
--- a/pysim_simple_server/testscript.py
+++ b/pysim_simple_server/testscript.py
@@ -253,6 +253,10 @@ def _normalise_params(kind, p):
for key in ('spi1', 'spi2'):
if out.get(key) and len(out[key]) != 2:
raise ScriptError('scp80: %s must be 1 byte' % key)
+ # The keyset number (KIc/KID b8..b5, TS 102 225 5.1.2): the keys and
+ # the counter stay preset-owned, the step only picks which keyset.
+ if p.get('kvn') not in (None, ''):
+ out['kvn'] = _int(p.get('kvn'), 'scp80 keyset number', 1, 15)
return out
if kind == 'status':
attempts = p.get('attempts')
@@ -355,6 +359,31 @@ def _normalise_content_check(c, default_level):
spec = _check_spec(c.get('value') if 'value' in c else c, 'raw check')
return {'kind': 'raw', 'mode': spec['mode'], 'value': spec['value'],
'on_fail': level}
+ if kind == 'por':
+ out = {'kind': 'por', 'on_fail': level}
+ status = c.get('status')
+ if status is not None and str(status).strip():
+ out['status'] = str(status).strip()
+ if c.get('sw') not in (None, ''):
+ out['sw'] = _check_spec(c.get('sw'), 'por check sw')
+ if c.get('data') not in (None, ''):
+ out['data'] = _check_spec(c.get('data'), 'por check data')
+ if not (out.get('status') or out.get('sw') or out.get('data')):
+ raise ScriptError('por check: status, sw or data is required')
+ return out
+ if kind == 'files':
+ val = c.get('files', c.get('value'))
+ if isinstance(val, str):
+ val = [x for x in re.split(r'[,\s]+', val) if x]
+ if not isinstance(val, list) or not val:
+ raise ScriptError('files check: a list of file paths is required')
+ files = []
+ for f in val:
+ path = re.sub(r'\s', '', str(f)).upper()
+ if not re.fullmatch(r'(?:[0-9A-F]{2})+', path) or len(path) < 4:
+ raise ScriptError('files check: each path must be hex FID bytes: %r' % f)
+ files.append(path)
+ return {'kind': 'files', 'files': files, 'on_fail': level}
raise ScriptError('unknown check kind %r' % c.get('kind'))
diff --git a/tests/test_testscript.py b/tests/test_testscript.py
index 57262ec..38aaed6 100644
--- a/tests/test_testscript.py
+++ b/tests/test_testscript.py
@@ -104,6 +104,40 @@ class TestValidation(unittest.TestCase):
'8103042300' + '82028182' + 'AA01BB' + '8D0600' + '68656C6C6F'
+ '83020000')
+ def test_scp80_keyset_number_and_new_content_checks(self):
+ # kvn (1..15) selects the preset's keyset; `por`/`files` are expect
+ # content checks (the PoR contents / the REFRESH file list).
+ script = T.normalise_script({'steps': [
+ {'type': 'action', 'kind': 'scp80',
+ 'params': {'apdu': '80E2900000', 'kvn': 2}},
+ {'type': 'expect', 'command': 'SEND SHORT MESSAGE',
+ 'checks': [{'kind': 'por', 'status': 'por_ok', 'sw': '6A8?',
+ 'data': {'mode': 'mask', 'value': 'AA??'}}]},
+ {'type': 'expect', 'command': 'REFRESH',
+ 'checks': [{'kind': 'files', 'value': '3F007F106F3A, 3F002FE2'}]},
+ ]}, _resolver)
+ self.assertEqual(script['steps'][0]['params']['kvn'], 2)
+ por = script['steps'][1]['checks'][0]
+ self.assertEqual(por['status'], 'por_ok')
+ self.assertEqual(por['sw'], {'mode': 'mask', 'value': '6A8?'})
+ self.assertEqual(por['data'], {'mode': 'mask', 'value': 'AA??'})
+ self.assertEqual(script['steps'][2]['checks'][0]['files'],
+ ['3F007F106F3A', '3F002FE2'])
+ # a keyset number outside 1..15, an empty por check and a bad path are
+ # refused before anything runs
+ with self.assertRaises(T.ScriptError):
+ T.normalise_script({'steps': [
+ {'type': 'action', 'kind': 'scp80',
+ 'params': {'apdu': '80E2', 'kvn': 0}}]}, _resolver)
+ with self.assertRaises(T.ScriptError):
+ T.normalise_script({'steps': [
+ {'type': 'expect', 'command': 'ANY',
+ 'checks': [{'kind': 'por'}]}]}, _resolver)
+ with self.assertRaises(T.ScriptError):
+ T.normalise_script({'steps': [
+ {'type': 'expect', 'command': 'ANY',
+ 'checks': [{'kind': 'files', 'value': ['zz']}]}]}, _resolver)
+
class TestMatchers(unittest.TestCase):
def test_match_value(self):
@@ -424,6 +458,65 @@ class TestRunnerActions(RunnerTestCase):
self.assertEqual(args[4], 'B00001') # tar override
self.assertEqual(args[2], '15') # kic stays preset-owned
+ def test_scp80_step_selects_the_keyset_by_kvn(self):
+ # the step's keyset number picks the keyset (v3.15.0: the normaliser
+ # dropped `kvn`, so scripts silently used the preset's first keyset)
+ scc = FakeScc()
+ preset = {'keysets': [
+ {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16,
+ 'kidKey': 'BB' * 16, 'cntr': '00000001'},
+ {'kic': '25', 'kid': '25', 'kicKey': 'CC' * 16,
+ 'kidKey': 'DD' * 16, 'cntr': '00000020'}],
+ 'tars': preset_tars()}
+ with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})) as build, \
+ mock.patch.object(S, '_send_secured_packet',
+ return_value={'success': True, 'sw': '9000',
+ 'response_data': None}):
+ run = self.run_script(FakeServer(scc), [
+ {'type': 'action', 'kind': 'scp80',
+ 'params': {'apdu': '80E2900000', 'kvn': 2}},
+ ], preset)
+ self.assertEqual(run['status'], 'ok', run['steps'])
+ args = build.call_args[0]
+ self.assertEqual(args[2], '25') # the second keyset's KIc/KID
+ self.assertEqual(args[3], '25')
+ self.assertEqual(args[5], '00000020') # and its counter
+ self.assertEqual(args[7], 'CC' * 16)
+ self.assertEqual(args[8], 'DD' * 16)
+ self.assertEqual(run['scp80_counters'], {2: '00000021'})
+ self.assertEqual(run['scp80_counter'], '00000021')
+
+ def test_preset_completeness_validates_the_step_keyset(self):
+ script = T.normalise_script({'steps': [
+ {'type': 'action', 'kind': 'scp80',
+ 'params': {'apdu': '80E2900000', 'kvn': 3}}]}, S._test_command_type)
+ preset = {'keysets': [{'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB',
+ 'cntr': '00000000'}],
+ 'tars': preset_tars()}
+ self.assertIn('keyset 3', S._test_preset_error(script, preset) or '')
+ good = T.normalise_script({'steps': [
+ {'type': 'action', 'kind': 'scp80',
+ 'params': {'apdu': '80E2900000', 'kvn': 1}}]}, S._test_command_type)
+ self.assertIsNone(S._test_preset_error(good, preset))
+
+ def test_run_preset_resolves_the_stored_id(self):
+ class Store:
+ def get(self, pid):
+ return {'id': pid, 'name': 'Card 1'} if pid == 'p1' else None
+
+ import types
+ server = types.SimpleNamespace(card_presets=Store())
+ preset, err = S._test_run_preset_from_body(server, {'preset_id': 'p1'})
+ self.assertEqual(preset['name'], 'Card 1')
+ self.assertIsNone(err)
+ preset, err = S._test_run_preset_from_body(server, {'preset_id': 'nope'})
+ self.assertIsNone(preset)
+ self.assertIn('not found', err)
+ inline = {'name': 'inline'}
+ preset, err = S._test_run_preset_from_body(server, {'preset': inline})
+ self.assertIs(preset, inline)
+ self.assertIsNone(err)
+
def test_preset_completeness_is_validated(self):
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'}},
@@ -435,6 +528,86 @@ class TestRunnerActions(RunnerTestCase):
no_tar = dict(full, tars=[])
self.assertIn('TAR', S._test_preset_error(script, no_tar) or '')
+ # A SEND SHORT MESSAGE announcing the PoR-in-submit (SPI2 bit 0x20): the
+ # TPDU carries a submit PDUs with UD = 02 71 00 AA BB (the deliver-style
+ # PoR header); the runner must decode it with the scp80 step's context.
+ SEND_SM_POR_CMD = 'D0188103011300820283810B0D01000181127F0005027100AABB'
+ # A REFRESH (qualifier 00 = NAA init + full FCN) with the file list
+ # 12 07 01 3F007F106F3A (one file: MF/7F10/6F3A).
+ REFRESH_FCN_CMD = 'D0128103010100820283811207013F007F106F3A'
+
+ def test_expect_decodes_the_submit_por(self):
+ scc = FakeScc()
+ preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16,
+ 'counter': '0000000A', 'tars': preset_tars()}
+ decoded = {'response_status': 'por_ok', 'tar': '000000',
+ 'decoded': {'last_status_word': '9000', 'last_response_data': 'AABB'}}
+ with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
+ mock.patch.object(S, '_send_secured_packet',
+ return_value={'success': True, 'sw': '9102',
+ 'response_data': None}), \
+ mock.patch.object(S, '_decode_por', return_value=decoded) as dec:
+ scc.push('8012', self.SEND_SM_POR_CMD, '9000')
+ scc.push('8014', '', '9000')
+ run = self.run_script(FakeServer(scc), [
+ {'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
+ 'check': {'sw': {'mode': 'mask', 'value': '91??'}, 'por': 'none'}},
+ {'type': 'expect', 'command': 'SEND SHORT MESSAGE',
+ 'checks': [{'kind': 'por', 'status': 'por_ok', 'sw': '9000'}],
+ 'respond': {'result': 'ok'}},
+ ], preset)
+ self.assertEqual(run['status'], 'ok', run['steps'])
+ self.assertEqual(run['steps'][1]['por']['response_status'], 'por_ok')
+ labels = {c['label']: c for c in run['steps'][1]['checks']}
+ self.assertTrue(labels['PoR status']['ok'], labels)
+ self.assertTrue(labels['PoR SW']['ok'], labels)
+ args = dec.call_args[0]
+ self.assertEqual(args[4], '0000000A') # the command's counter
+ self.assertEqual(args[5], 'AA' * 16) # the keyset's keys
+ self.assertEqual(args[6], 'BB' * 16)
+ self.assertEqual(args[-1], '027100027100aabb')
+
+ def test_expect_por_check_without_a_prior_scp80_fails(self):
+ scc = FakeScc()
+ scc.push('80F2', '', '9102')
+ scc.push('8012', self.SEND_SM_POR_CMD, '9000')
+ scc.push('8014', '', '9000')
+ run = self.run_script(FakeServer(scc), [
+ {'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
+ 'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
+ {'type': 'expect', 'command': 'SEND SHORT MESSAGE',
+ 'checks': [{'kind': 'por', 'status': 'por_ok'}],
+ 'respond': {'result': 'ok'}},
+ ])
+ self.assertIsNone(run['steps'][1].get('por'))
+ por_check = [c for c in run['steps'][1]['checks'] if c['label'] == 'PoR'][0]
+ self.assertFalse(por_check['ok'])
+ self.assertIn('no PoR', por_check['actual'])
+
+ def test_expect_checks_the_refresh_file_list(self):
+ scc = FakeScc()
+ scc.push('80F2', '', '9102')
+ scc.push('8012', self.REFRESH_FCN_CMD, '9000')
+ scc.push('8014', '', '9000')
+ run = self.run_script(FakeServer(scc), [
+ {'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
+ 'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
+ {'type': 'expect', 'command': 'REFRESH', 'qualifier': '00',
+ 'checks': [{'kind': 'files', 'value': '3F007F106F3A'}],
+ 'respond': {'result': 'ok'}},
+ ])
+ self.assertEqual(run['status'], 'ok', run['steps'])
+ labels = {c['label']: c for c in run['steps'][1]['checks']}
+ self.assertTrue(labels['Command']['ok'], labels)
+ self.assertTrue(labels['Qualifier']['ok'], labels)
+ self.assertTrue(labels['Files']['ok'], labels)
+ self.assertEqual(labels['Files']['actual'], '3F007F106F3A')
+
+ def test_parse_file_list_splits_concatenated_paths(self):
+ raw = bytes.fromhex('D016810301010082028381120B023F002FE23F007F106F3A')
+ self.assertEqual(S._parse_file_list(raw), ['3F002FE2', '3F007F106F3A'])
+ self.assertEqual(S._parse_file_list(bytes.fromhex('D009810301010082028381')), [])
+
def test_file_write_and_read_actions(self):
class FakeLchan:
selected_file = None