From 0b9e9e4c5ee4dfee1487e4edbd4434ea271b785e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 4 Oct 2026 17:48:29 +0300 Subject: [PATCH] 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. --- docs/api.md | 13 ++- docs/test-scripts.md | 122 +++++++++++++++++++++++++++-- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 50 ++++++++++-- frontend/sw.js | 2 +- frontend/tests/testscripts.test.js | 29 +++++++ pyproject.toml | 2 +- pysim_simple_server/server.py | 77 +++++++++++++----- pysim_simple_server/testscript.py | 72 ++++++++++++++--- tests/test_testscript.py | 98 ++++++++++++++++++++++- 11 files changed, 417 insertions(+), 52 deletions(-) diff --git a/docs/api.md b/docs/api.md index 91bab2a..8b2d973 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/test-scripts.md b/docs/test-scripts.md index 427177d..6e3f0a9 100644 --- a/docs/test-scripts.md +++ b/docs/test-scripts.md @@ -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": "", "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": ""}], + "respond": {"result": "ok"}}, + {"type": "action", "kind": "file-read", "params": {"path": ""}, + "check": {"data": {"mode": "mask", "value": ""}}} + ]} +``` + +### Worksheet 2 - incoming data → particular PoR, no other actions + +Inline PoR: + +```json +{"name": "applet data (inline PoR)", + "steps": [ + {"type": "action", "kind": "scp80", + "params": {"apdu": "", "tar": "", "spi2": "01"}, + "check": {"sw": "9000", "por": "ok", + "data": {"mode": "mask", "value": ""}}} + ]} +``` + +PoR-in-submit: + +```json +{"name": "applet data (SEND SM PoR)", + "steps": [ + {"type": "action", "kind": "scp80", + "params": {"apdu": "", "tar": "", "spi2": "21"}, + "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}}, + {"type": "expect", "command": "SEND SHORT MESSAGE", + "checks": [{"kind": "por", "status": "por_ok", "sw": "", + "data": {"mode": "mask", "value": ""}}], + "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 diff --git a/frontend/help-ru.html b/frontend/help-ru.html index b59f05f..0ad18c2 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -563,7 +563,7 @@

8.9 Тест-скрипт

Тест-скрипт — упорядоченный список действий и ожиданий проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия Очистить отчёт. Скрипты хранятся в хранилище на сервере (~/.pysim-simple-server/test_scripts.json, --test-scripts), поэтому все браузеры работают с одним набором; экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).

    -
  • Действия — 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).
  • +
  • Действия — 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 805d10d..453ef54 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -562,7 +562,7 @@

    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 the server-side store (~/.pysim-simple-server/test_scripts.json, --test-scripts), 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).

      -
    • 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).
    • +
    • Actions — Envelope (Event Download: event type + data), Menu Selection (item id 1–255, or the item's text with an exact/contains match — the text is resolved against the card's menu, which follows the applet's install parameters), 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 90f9ec8..38ef19b 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.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', + '
      ' + 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')}]) + '
      '); } 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': 'шаг должен быть действием или ожиданием', diff --git a/frontend/sw.js b/frontend/sw.js index c628a0f..0ea78e6 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v379'; +const CACHE = 'simple-v380'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js index 286b555..412ef89 100644 --- a/frontend/tests/testscripts.test.js +++ b/frontend/tests/testscripts.test.js @@ -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', diff --git a/pyproject.toml b/pyproject.toml index f03cfd6..555484f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 33ce1af..0496e51 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -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 diff --git a/pysim_simple_server/testscript.py b/pysim_simple_server/testscript.py index 8ed409b..7cace61 100644 --- a/pysim_simple_server/testscript.py +++ b/pysim_simple_server/testscript.py @@ -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) diff --git a/tests/test_testscript.py b/tests/test_testscript.py index b3bab0c..596799f 100644 --- a/tests/test_testscript.py +++ b/tests/test_testscript.py @@ -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)