v3.17.0: test scripts for the applet handoff - menu by text, spec-order TRs, codings

The applet-testing batch agreed after v3.15/3.16:

- menu-select by text: the step names the item by `text` (+ `mode`
  exact/contains, `case_sensitive`) instead of the install-dependent id
  (both or neither is refused); the runner resolves it against the card's
  cached menu and raises a step error naming the available items when nothing
  (or several items) matches.  The cache is refreshed on every SET UP MENU
  fetch - the proactive chain and the expect step - so an applet install (new
  items/ids after its REFRESH) no longer leaves a stale menu behind.  The
  step editor offers the id or the text + mode and the client-side pre-check
  bounds them.
- TERMINAL RESPONSE object order: both builders emitted the Item identifier
  before the Result (and the scripted builder its Text string before it too);
  TS 102 223 6.8.0 mandates Command details, Device identities, Result,
  Duration, Text string, Item identifier.  `testscript.build_tr` and
  `_menu_send_response` now follow it; `respond.raw` is documented as
  appended after the standard objects, and the exact-byte tests pin the order.
- Text codings: the scripted response codes the GSM default alphabet via the
  GSM 03.38 codec ('00' packed, '04' 8-bit) instead of latin-1, keeps '08'
  UCS2 and sends the spec's null text string for an empty answer; the packing
  helper is shared with the interactive path (`testscript.pack_gsm7`).  A
  UCS2 GET INPUT answer is documented and tested.
- docs/test-scripts.md gained the handoff brief for the applet agent: the
  value-collection table, fill-in worksheets for the two applet scenarios, a
  menu-by-text example, the validation checklist and a format stamp; help
  EN/RU and docs/api.md updated.

Tests: 809 frontend + 705 Python; sw cache simple-v380.
This commit is contained in:
2026-10-04 17:48:29 +03:00
parent 8430dd1694
commit 0b9e9e4c5e
11 changed files with 417 additions and 52 deletions
+9 -4
View File
@@ -667,8 +667,11 @@ 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`),
`menu-select` (`item_id` 1-255, or `text` + `mode` `exact`/`contains` with an
optional `case_sensitive` - the text is resolved against the card's cached
SET UP MENU, which is refreshed whenever the card sends the menu), `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 `kvn` (keyset
number 1-15), `tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always
@@ -691,8 +694,10 @@ 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).
order-insensitively); `respond` is the TERMINAL RESPONSE in the TS 102 223
6.8.0 object order (`result` name or value; `text`+`dcs` for GET INKEY/GET
INPUT, `dcs` `00` packed / `04` 8-bit / `08` UCS2; `item_id`; extra `raw`
TLVs appended after the standard objects).
The response is the initial state (`running: true`), the final counter
(`scp80_counter`) and the step list; poll `/api/test/status`. The PWA writes
+117 -5
View File
@@ -12,6 +12,86 @@ localStorage export imports as-is), and any API client can create or run them
(`POST /api/test/run` with an inline `script` or a stored `script_id` - see
`docs/api.md`). While a run is active the card belongs to the script.
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
RESPONSE codings since 3.17.0.
## Writing a test for an applet
Collect these values before writing the JSON (from the applet's source/spec
and the card's preset):
| What | Where it goes | Notes |
|---|---|---|
| Applet TAR (3 bytes) + MSL, or an explicit SPI1 | `scp80.params.tar` / `spi1` | the card preset must carry the TAR with its MSL, or the step passes `spi1` |
| Keyset number | `scp80.params.kvn` (1–15) | only when the applet's keys are not the preset's first keyset |
| Trigger C-APDUs | `scp80.params.apdu` | one secured packet per `scp80` step; the preset provides keys and counter |
| Expected file paths + contents | `file-read.params.path` + `check.data` | a mask must have **exactly** the actual value's length |
| Expected proactive command(s) | `expect.command` + `qualifier`/`checks` | REFRESH `qualifier: "00"` = NAA init + full FCN, `files` asserts the changed EFs |
| PoR transport + expected PoR | `scp80.check.por`/`check.data` (inline) or `expect SEND SHORT MESSAGE` + `por` check | pick per the SPI2 the applet expects |
| Delivery timing | a `status` action when the card delivers on poll | the runner never polls by itself |
### Worksheet 1 - RFM update → file change → REFRESH (full FCN)
```json
{"name": "applet RFM update",
"steps": [
{"type": "action", "kind": "scp80",
"params": {"apdu": "<RFM C-APDU>", "tar": "<applet TAR>", "kvn": 1, "spi2": "01"},
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
"label": "RFM update"},
{"type": "expect", "command": "REFRESH", "qualifier": "00",
"checks": [{"kind": "files", "value": "<changed FIDs, e.g. 3F007F206F07>"}],
"respond": {"result": "ok"}},
{"type": "action", "kind": "file-read", "params": {"path": "<EF path>"},
"check": {"data": {"mode": "mask", "value": "<expected bytes, ? = wildcard>"}}}
]}
```
### Worksheet 2 - incoming data → particular PoR, no other actions
Inline PoR:
```json
{"name": "applet data (inline PoR)",
"steps": [
{"type": "action", "kind": "scp80",
"params": {"apdu": "<data C-APDU>", "tar": "<applet TAR>", "spi2": "01"},
"check": {"sw": "9000", "por": "ok",
"data": {"mode": "mask", "value": "<expected PoR bytes>"}}}
]}
```
PoR-in-submit:
```json
{"name": "applet data (SEND SM PoR)",
"steps": [
{"type": "action", "kind": "scp80",
"params": {"apdu": "<data C-APDU>", "tar": "<applet TAR>", "spi2": "21"},
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}},
{"type": "expect", "command": "SEND SHORT MESSAGE",
"checks": [{"kind": "por", "status": "por_ok", "sw": "<R-APDU SW>",
"data": {"mode": "mask", "value": "<expected response data>"}}],
"respond": {"result": "ok"}}
]}
```
### Validation checklist
1. **Client-side**: the PWA import (or `testScriptProblem`) rejects structural
mistakes; the server validates again with the same engine at run start.
2. **Import** the JSON (PWA → Simulator → Test script → Import, or
`POST /api/test/scripts`), select the matched preset, **Run**.
3. **Read the report**: every action/expectation shows expected vs actual per
check; ⚠ = warning (continues), ✗ = error (stops). A failed step names the
reason (`note`).
4. **Iterate**: a mismatch on `data` usually means the mask length differs from
the actual value, or the value changed between runs - mask the varying
bytes (counter, date-time) with `?`.
5. **Counters**: the run starts from the preset's stored counter and persists
each accepted packet's next value; consecutive runs continue correctly.
## Document shape
```json
@@ -52,7 +132,7 @@ continue correctly.
| kind | params | notes |
|---|---|---|
| `envelope` | `event` 0–255, `data` hex (optional) | ENVELOPE(Event Download) |
| `menu-select` | `item_id` 1–255 | ENVELOPE(Menu Selection) |
| `menu-select` | `item_id` 1–255 **or** `text` + `mode` `exact`/`contains` (+ `case_sensitive`), not both | ENVELOPE(Menu Selection); the text is resolved against the card's cached menu - refreshed whenever the card sends SET UP MENU, so it follows the applet's install parameters |
| `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 |
@@ -116,10 +196,26 @@ 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 TERMINAL RESPONSE for the fetched command, in the TS 102 223 §6.8.0
object order (Command details, Device identities, **Result**, Duration, Text
string, Item identifier, …):
- `result` - name or hex (`ok`, `partial`, `missing`, `refused`,
`not_understood`, `modified`, `cancel`, `back`, `timeout`, `no_response`).
- `text` + `dcs` - the Text string for a GET INKEY / GET INPUT answer. `dcs`
uses the spec's recommended codings: `"00"` GSM default alphabet 7 bits
**packed**, `"04"` GSM default alphabet 8 bits (one octet per character),
`"08"` **UCS2** (UTF-16-BE). An empty `text` sends the null text string
(Length `00`).
- `item_id` - the Item identifier for a successful SELECT ITEM answer.
- `raw` - extra COMPREHENSION-TLVs appended after the standard objects.
UCS2 answer to a GET INPUT:
```json
{"type": "expect", "command": "GET INPUT",
"respond": {"result": "ok", "text": "Пароль", "dcs": "08"}}
```
## The two PoR transports (SPI2)
@@ -149,6 +245,22 @@ pending command the next step does not expect.
## Worked examples
### Menu selection by text (the id varies with the install parameters)
```json
{"name": "applet menu",
"steps": [
{"type": "action", "kind": "menu-select",
"params": {"text": "Settings", "mode": "exact"}},
{"type": "expect", "command": "ANY", "respond": {"result": "ok"}}
]}
```
The text is matched against the card's cached menu; the cache is refreshed
whenever the card sends SET UP MENU (e.g. after an install + REFRESH). If the
script must wait for the menu, add a `status` action and an
`expect SET UP MENU` before the selection.
### RFM update → file change → REFRESH (NAA init + full FCN)
```json
+1 -1
View File
@@ -563,7 +563,7 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением &mdash; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
<li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
<li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
+1 -1
View File
@@ -562,7 +562,7 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1&ndash;255), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (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 &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li>
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match &mdash; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (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 &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li>
<li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
<li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); each check is reported with expected vs actual values.</li>
<li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based and individual scripts export/import as JSON.</li>
+44 -6
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.16.0';
const SIMPLE_VERSION = '3.17.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -6964,6 +6964,17 @@ function testCurrent() {
return scripts[_testCurrentIdx] || null;
}
// The menu-select target: an item id (1-255) or the item text - the id varies
// with the applet's install parameters, so the text can be matched instead.
function testMenuSelectProblem(p) {
const raw = String(p && p.item_id !== undefined && p.item_id !== null ? p.item_id : '').trim();
const hasText = !!String((p && p.text) || '').trim();
if (raw !== '' && !(p.item_id >= 1 && p.item_id <= 255)) return t('item id must be 1..255');
if (!hasText && raw === '') return t('item id or text is required');
if (hasText && raw !== '') return t('give the item id or the text, not both');
return '';
}
// Local sanity check (the server validates again); '' when the script looks good.
function testScriptProblem(script) {
if (!script || typeof script !== 'object') return t('Script must be an object');
@@ -6986,7 +6997,10 @@ function testScriptProblem(script) {
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 === 'menu-select') {
const problem = testMenuSelectProblem(p);
if (problem) return at + problem;
}
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
} else if (s.type === 'expect') {
if (!String(s.command || '').trim()) return at + t('command is required');
@@ -7037,7 +7051,11 @@ function testStepSummary(step) {
const p = step.params || {};
let s = '';
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) ' + (p.text
? 'text ' + (p.mode === 'contains' ? '~' : '=') + JSON.stringify(p.text)
: 'item=' + p.item_id);
}
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
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 || '');
@@ -7300,7 +7318,11 @@ function testStepRender() {
html += testFormRow('Event type', testFormInput('test-f-event', p.event === undefined ? 3 : p.event, '0..255'));
html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100'));
} else if (step.kind === 'menu-select') {
html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? 1 : p.item_id, '1..255'));
html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? '' : p.item_id, '1..255'));
html += testFormRow('\u2026 or item text',
'<div class="flex gap-2">' + testFormInput('test-f-itemtext', p.text, t('menu item text'))
+ testFormSelect('test-f-itemmode', p.mode || 'exact',
[{v: 'exact', l: t('Exact')}, {v: 'contains', l: t('Contains')}]) + '</div>');
} else if (step.kind === 'file-write' || step.kind === 'file-read') {
html += testFormRow('File path', testFormInput('test-f-path', p.path, 'ADF.USIM/EF.EST or MF/2FE2'));
html += testFormRow('Mode', testFormSelect('test-f-mode', p.mode || 'auto', [
@@ -7492,7 +7514,16 @@ function testStepCollect() {
step.kind = kind;
const p = {};
if (kind === 'envelope') { p.event = num('test-f-event', 3); p.data = hexNoMask('test-f-data'); }
else if (kind === 'menu-select') { p.item_id = num('test-f-item', 1); }
else if (kind === 'menu-select') {
const text = val('test-f-itemtext');
if (text) {
p.text = text;
p.mode = val('test-f-itemmode') || 'exact';
} else {
const id = num('test-f-item', 0);
if (id) p.item_id = id;
}
}
else if (kind === 'file-write' || kind === 'file-read') {
p.path = val('test-f-path');
p.mode = val('test-f-mode') || 'auto';
@@ -7564,7 +7595,10 @@ function testStepFormError() {
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') {
const problem = testMenuSelectProblem(p);
if (problem) return problem;
}
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');
} else if (!step.command) {
@@ -20928,6 +20962,10 @@ const LANG_RU = {
'APDU is empty': 'APDU пуст',
'SCP80 needs an APDU or a secured packet': 'SCP80 требует APDU или готовый защищённый пакет',
'item id must be 1..255': 'item id должен быть 1..255',
'item id or text is required': 'нужен item id или текст пункта',
'give the item id or the text, not both': 'укажите item id или текст, но не оба',
'… or item text': '… или текст пункта',
'menu item text': 'текст пункта меню',
'file path is empty': 'путь к файлу пуст',
'command is required': 'команда обязательна',
'step must be an action or an expectation': 'шаг должен быть действием или ожиданием',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v379';
const CACHE = 'simple-v380';
const URLS = [
'index.html',
'help.html',
+29
View File
@@ -31,6 +31,7 @@ function extractFunc(src, name, asyncFn) {
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'testTemplate'));
eval(extractFunc(html, 'testMenuSelectProblem'));
eval(extractFunc(html, 'testScriptProblem'));
eval(extractFunc(html, 'testCheckSummary'));
eval(extractFunc(html, 'testStepSummary'));
@@ -85,6 +86,13 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
steps: [{ type: 'action', kind: 'scp80', params: { apdu: '' } }] }), /SCP80/);
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'menu-select', params: { item_id: 0 } }] }), /item id/);
// the item can be selected by its text instead of the install-dependent id
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
params: { text: 'My menu', mode: 'contains' } }] }), '');
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
params: {} }] }), /item id or text/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
params: { item_id: 1, text: 'My menu' } }] }), /not both/);
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
@@ -102,6 +110,10 @@ test('testStepSummary renders actions', () => {
testStepSummary({ type: 'action', kind: 'menu-select', params: { item_id: 3 },
check: { sw: { mode: 'mask', value: '91??' } } }),
'ENVELOPE(Menu Selection) item=3 · SW ~91??');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'menu-select',
params: { text: 'My menu', mode: 'exact' } }),
'ENVELOPE(Menu Selection) text ="My menu"');
assert.strictEqual(
testStepSummary({ type: 'action', kind: 'apdu', params: { apdu: '00A4' } }),
'APDU 00A4');
@@ -172,6 +184,23 @@ test('the SCP80 source switch sticks and preserves the other value', () => {
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
});
test('the menu-select editor collects the item id or the text', () => {
// by text (the id varies with the applet's install parameters)
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
_testEditChecks = [];
_testEditStepIndex = 0;
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
'test-f-itemmode': 'contains', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params, { text: 'My menu', mode: 'contains' });
// by id when no text is given
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '3', 'test-f-itemtext': '',
'test-f-itemmode': 'exact', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
});
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',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.16.0"
version = "3.17.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.
+58 -19
View File
@@ -33,7 +33,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.16.0'
VERSION = '3.17.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5210,6 +5210,17 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
except Exception as e:
sys.stderr.write('FETCH log error: %s (raw=%s)\n' % (e, raw.hex()))
try:
if cmd_type == 0x25:
# Keep the cached menu current: text-based menu selection
# and the Phone tab must see the menu the card sends now
# (an applet install changes the items and their ids).
menu = _parse_setup_menu_command(raw)
if menu and _server_ref:
_server_ref.sim_menu = menu
except Exception as e:
sys.stderr.write('SET UP MENU cache error: %s (raw=%s)\n'
% (e, raw.hex()))
try:
if raw and cmd_type in (0x03, 0x04):
_handle_card_poll_command(cmd_type, raw)
@@ -5446,20 +5457,10 @@ def _validate_input_response(pd, text):
def _pack_gsm7(septets):
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing)."""
out = bytearray()
acc = 0
bits = 0
for s in septets:
acc |= (s & 0x7F) << bits
bits += 7
while bits >= 8:
out.append(acc & 0xFF)
acc >>= 8
bits -= 8
if bits:
out.append(acc & 0xFF)
return bytes(out)
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing).
One implementation, shared with the scripted TR builder
(`testscript.pack_gsm7`)."""
return testscript.pack_gsm7(septets)
def _input_text_tlv(pd, text):
@@ -5513,12 +5514,13 @@ def _menu_send_response(server, result, item_id=None, text=None):
duration_tlv = bytes([0x04, 0x02, pd['duration_unit'], value])
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
# TS 102 223 6.8.0 object order: Result (C), Duration (D), Text string (E),
# Item identifier (F).
tr_data = cd + di
tr_data += bytes([0x83, 0x02, gr, 0x00])
tr_data += duration_tlv + text_tlv
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
tr_data += bytes([0x90, 0x01, item_id])
tr_data += bytes([0x83, 0x02, gr, 0x00])
# Table 6.8.0 order after Result: Duration (D) then Text string (E)
tr_data += duration_tlv + text_tlv
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
tr_rv = scc._tp.send_apdu(tr_hex)
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
@@ -5912,6 +5914,34 @@ def _test_run_scp80(server, step, ctx):
return data, sw, sent, por, counter
def _menu_item_by_text(server, p):
"""Resolve a menu-select step's text against the cached SET UP MENU items
(the item ids vary with the applet's install parameters, so a script
matches the text the card actually shows). Raises a ScriptError naming
the available items when nothing - or more than one item - matches."""
menu = getattr(server, 'sim_menu', None) or {}
items = menu.get('items') or []
spec = {'mode': p.get('mode', 'exact'), 'value': p['text'],
'case_sensitive': p.get('case_sensitive', True)}
matches = [it for it in items
if testscript.match_text(spec, it.get('text') or '')]
if not matches:
listing = ', '.join('%s=%r' % (it.get('id'), it.get('text'))
for it in items)
raise testscript.ScriptError(
'menu-select: no menu item matches %r (%s)'
% (p['text'], 'menu: ' + listing if listing
else 'no menu cached - fetch the menu (a SET UP MENU '
'expectation) first'))
if len(matches) > 1:
raise testscript.ScriptError(
'menu-select: %d items match %r (%s) - use the item id'
% (len(matches), p['text'],
', '.join('%s=%r' % (it.get('id'), it.get('text'))
for it in matches)))
return matches[0]['id']
def _test_run_action(server, step, ctx):
scc = server.scc
kind = step['kind']
@@ -5924,7 +5954,10 @@ def _test_run_action(server, step, ctx):
drain=False)
sent = 'ENVELOPE(Event Download) type=0x%02X' % p['event']
elif kind == 'menu-select':
tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, p['item_id']])
item_id = p.get('item_id')
if item_id is None:
item_id = _menu_item_by_text(server, p)
tlv = bytes([0xD3, 0x07, 0x02, 0x02, 0x01, 0x81, 0x90, 0x01, item_id])
sent = '%sc20000%02x%s' % (scc.cat_cla, len(tlv), tlv.hex())
server.menu_active = True
data, sw = scc._tp.send_apdu(sent)
@@ -6061,6 +6094,12 @@ def _test_run_expect(server, step, pending, ctx=None):
# 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
if cmd_type == 0x25:
# The card may re-send its menu (e.g. after an applet install); keep
# the cache that text-based menu selection resolves against current.
menu = _parse_setup_menu_command(raw)
if menu:
server.sim_menu = menu
checks = []
want_type = step['command'].get('type')
type_ok = want_type is None or cmd_type == want_type
+62 -10
View File
@@ -21,10 +21,13 @@ profiler), e.g. ``{"mode": "mask", "value": "91??"}``.
import re
import gsm0338 # registers the 'gsm03.38' codec (the default alphabet coding)
__all__ = [
'ScriptError', 'ACTION_KINDS', 'FAIL_LEVELS', 'POR_CHECKS', 'RESULT_NAMES',
'normalise_script', 'normalise_step', 'normalise_respond',
'match_value', 'match_text', 'match_item', 'combine_levels', 'build_tr',
'pack_gsm7',
]
ACTION_KINDS = ('envelope', 'menu-select', 'file-write', 'file-read', 'apdu',
@@ -216,7 +219,25 @@ def _normalise_params(kind, p):
return {'event': _int(p['event'], 'envelope event', 0, 255),
'data': _data_hex(p.get('data'), 'envelope data', allow_empty=True)}
if kind == 'menu-select':
return {'item_id': _int(p.get('item_id'), 'menu item_id', 1, 255)}
# The item is named by its id, or by its text - the id varies with the
# applet's install parameters, so a script can match the text the card
# actually shows (the runner resolves it against the cached menu).
out = {}
if p.get('item_id') not in (None, ''):
out['item_id'] = _int(p.get('item_id'), 'menu item_id', 1, 255)
text = p.get('text')
if text is not None and str(text).strip():
out['text'] = str(text)
mode = str(p.get('mode') or 'exact').lower()
if mode not in ('exact', 'contains'):
raise ScriptError('menu-select: mode must be exact or contains')
out['mode'] = mode
out['case_sensitive'] = bool(p.get('case_sensitive', True))
if not out:
raise ScriptError('menu-select: item_id or text is required')
if 'item_id' in out and 'text' in out:
raise ScriptError('menu-select: give item_id or text, not both')
return out
if kind in ('file-write', 'file-read'):
path = str(p.get('path') or '').strip()
if not path:
@@ -417,26 +438,57 @@ def normalise_respond(respond, cmd_type=None):
# ─── scripted TERMINAL RESPONSE ─────────────────────────────────────────
def pack_gsm7(septets):
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing)."""
out = bytearray()
acc = 0
bits = 0
for s in septets:
acc |= (s & 0x7F) << bits
bits += 7
while bits >= 8:
out.append(acc & 0xFF)
acc >>= 8
bits -= 8
if bits:
out.append(acc & 0xFF)
return bytes(out)
def _encode_text(text, dcs):
"""Text string coding for a scripted TERMINAL RESPONSE (TS 102 223 8.15):
'00' = GSM default alphabet 7 bits packed, '04' (or any other 8-bit
scheme) = GSM default alphabet 8 bits (TS 23.038 via the gsm03.38 codec,
bit 8 clear), '08' = UCS2. Matches the interactive GET INKEY/GET INPUT
response codings."""
if (dcs & 0x0C) == 0x08:
return text.encode('utf-16-be')
return text.encode('latin-1', 'replace')
if dcs == 0x00:
return pack_gsm7(text.encode('gsm03.38'))
return text.encode('gsm03.38')
def build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond):
"""Flat COMPREHENSION-TLV TERMINAL RESPONSE payload (TS 102 223 6.8):
command details + device identities + optional item identifier / text
string / raw TLVs + result. Matches the interactive menu TR layout."""
"""Flat COMPREHENSION-TLV TERMINAL RESPONSE payload (TS 102 223 6.8) in the
6.8.0 object order: command details, device identities, Result, then the
command-specific objects (Text string for GET INKEY/GET INPUT, Item
identifier for SELECT ITEM) and any extra raw TLVs. Matches the
interactive menu TR layout."""
out = bytearray([0x81, 0x03, cmd_num & 0xFF, cmd_type & 0xFF, 0x00])
out += bytes([0x82, 0x02, dev_dst & 0xFF, dev_src & 0xFF])
result = int(respond.get('result', 0))
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
if respond.get('text') is not None:
text = str(respond['text'])
if text == '':
# TS 102 223 8.15: a null text string is Length 00, no value part
out += bytes([0x8D, 0x00])
else:
dcs = int(respond.get('dcs', 0x00))
body = _encode_text(text, dcs)
out += bytes([0x8D, len(body) + 1, dcs]) + body
if respond.get('item_id') is not None and result == 0x00:
out += bytes([0x90, 0x01, respond['item_id'] & 0xFF])
if respond.get('raw'):
out += bytes.fromhex(respond['raw'])
if respond.get('text') is not None:
dcs = int(respond.get('dcs', 0x00))
body = _encode_text(respond['text'], dcs)
out += bytes([0x8D, len(body) + 1, dcs]) + body
out += bytes([0x83, 0x02, result & 0xFF, 0x00])
return bytes(out)
+94 -4
View File
@@ -94,15 +94,51 @@ class TestValidation(unittest.TestCase):
T.normalise_script({'steps': [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 'x'}}]}, _resolver)
def test_respond_text_and_raw(self):
def test_respond_text_codings_and_raw(self):
# TS 102 223 8.15 recommended codings: '00' GSM default alphabet
# 7 bits packed, '04' GSM default alphabet 8 bits, '08' UCS2.
respond = T.normalise_respond({'result': 'ok', 'text': 'hello', 'dcs': '00',
'raw': 'aa01bb'})
self.assertEqual(respond['text'], 'hello')
self.assertEqual(respond['raw'], 'AA01BB')
# 6.8.0 object order: command details, device identities, Result,
# then the command-specific objects (here Text), then the raw extras.
tr = T.build_tr(4, 0x23, 0x81, 0x82, respond)
self.assertEqual(tr.hex().upper(),
'8103042300' + '82028182' + 'AA01BB' + '8D0600' + '68656C6C6F'
+ '83020000')
'8103042300' + '82028182' + '83020000'
+ '8D0600' + 'E8329BFD06' + 'AA01BB')
# 8 bits: one octet per character
tr = T.build_tr(4, 0x23, 0x81, 0x82, {'result': 0x00, 'text': 'hello', 'dcs': 0x04})
self.assertEqual(tr.hex().upper(),
'8103042300' + '82028182' + '83020000' + '8D0604' + '68656C6C6F')
# UCS2 (UTF-16-BE): 'A' U+0041, 'Ж' U+0416
tr = T.build_tr(4, 0x23, 0x81, 0x82,
{'result': 0x00, 'text': 'A\u0416', 'dcs': 0x08})
self.assertEqual(tr.hex().upper(),
'8103042300' + '82028182' + '83020000' + '8D0508' + '00410416')
# an empty answer is the null text string (Length 00, no DCS)
tr = T.build_tr(4, 0x23, 0x81, 0x82, {'result': 0x00, 'text': ''})
self.assertEqual(tr.hex().upper(),
'8103042300' + '82028182' + '83020000' + '8D00')
def test_menu_select_params_accept_id_or_text(self):
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'menu-select', 'params': {'item_id': 3}},
{'type': 'action', 'kind': 'menu-select',
'params': {'text': 'My menu', 'mode': 'contains', 'case_sensitive': False}},
]}, _resolver)
self.assertEqual(script['steps'][0]['params'], {'item_id': 3})
self.assertEqual(script['steps'][1]['params'],
{'text': 'My menu', 'mode': 'contains', 'case_sensitive': False})
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'menu-select',
'params': {}}]}, _resolver)
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'menu-select',
'params': {'item_id': 1, 'text': 'x'}}]}, _resolver)
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'menu-select',
'params': {'text': 'x', 'mode': 'nope'}}]}, _resolver)
def test_scp80_keyset_number_and_new_content_checks(self):
# kvn (1..15) selects the preset's keyset; `por`/`files` are expect
@@ -172,8 +208,10 @@ class TestMatchers(unittest.TestCase):
class TestBuildTr(unittest.TestCase):
def test_select_item_response_carries_the_identifier(self):
# TS 102 223 6.8.0: the item identifier (F) follows the Result (C)
tr = T.build_tr(3, 0x24, 0x82, 0x81, {'result': 0x00, 'item_id': 7})
self.assertEqual(tr.hex().upper(), '81030324008202828190010783020000')
self.assertEqual(tr.hex().upper(),
'81030324008202828183020000900107')
def test_cancel_response_has_no_item_identifier(self):
tr = T.build_tr(3, 0x24, 0x82, 0x81, {'result': 0x10, 'item_id': 7})
@@ -392,6 +430,58 @@ class TestRunnerActions(RunnerTestCase):
'params': {'item_id': 1}, 'check': {'sw': '91??'}}])
self.assertTrue(server.menu_active)
# SET UP MENU (title 'Menu', item 1 = 'One') as the card sends it.
SETUP_MENU_CMD = 'D01581030125008202828185044D656E758F04014F6E65'
def test_expect_updates_the_cached_menu_and_text_selection_resolves(self):
# The item ids vary with the applet's install parameters: the menu the
# card sends must refresh the cache, and a text selection resolves
# against it (the live id is not in the script).
scc = FakeScc()
scc.push('80F2', '', '9102')
scc.push('8012', self.SETUP_MENU_CMD, '9000')
scc.push('8014', '', '9000')
scc.push('80C2', '', '9000')
server = FakeServer(scc)
run = self.run_script(server, [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
{'type': 'expect', 'command': 'SET UP MENU', 'respond': {'result': 'ok'}},
{'type': 'action', 'kind': 'menu-select', 'params': {'text': 'One'}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertEqual(server.sim_menu['items'],
[{'id': 1, 'text': 'One'}])
# the resolved Menu Selection TLV carries the item id the card gave
self.assertIn('D30702020181900101', ''.join(scc.sent))
def test_menu_text_selection_reports_the_available_items(self):
scc = FakeScc()
server = FakeServer(scc)
server.sim_menu = {'title': 'Menu', 'items': [{'id': 3, 'text': 'Alpha'}]}
run = self.run_script(server, [
{'type': 'action', 'kind': 'menu-select', 'params': {'text': 'Beta'}},
])
self.assertEqual(run['status'], 'error')
note = run['steps'][0].get('note') or ''
self.assertIn("no menu item matches 'Beta'", note)
self.assertIn("3='Alpha'", note)
self.assertEqual(scc.sent, [])
def test_menu_text_selection_refuses_an_ambiguous_match(self):
scc = FakeScc()
server = FakeServer(scc)
server.sim_menu = {'items': [{'id': 1, 'text': 'Alpha one'},
{'id': 2, 'text': 'Alpha two'}]}
run = self.run_script(server, [
{'type': 'action', 'kind': 'menu-select',
'params': {'text': 'Alpha', 'mode': 'contains'}},
])
self.assertEqual(run['status'], 'error')
note = run['steps'][0].get('note') or ''
self.assertIn('2 items match', note)
self.assertIn('use the item id', note)
def test_scp80_uses_the_preset_and_advances_the_counter(self):
scc = FakeScc()
server = FakeServer(scc)