v3.22.0 (review fixes): the quota zero, the suite repair paths, the docs

Second review round on the unpushed v3.20.0-v3.22.0 batch:

- install parameters: a zero memory quota (nv_quota: 0 - the vendor
  reference form's C7 02 0000 C8 02 0000) is composed instead of being
  dropped as "unset" (only None/blank leaves the TLV out); a truncated EF
  length in stk_params is refused instead of read as an empty EF.
- test suites: a suite whose member script is gone is repairable - the
  update endpoint accepts pruning a dangling reference (a removal of a
  live script stays refused; the PWA's row Delete prunes it), and
  reconcile_scripts_to_suites() lists an orphan even when its suite_id
  write is refused by the validator, so a stored script that no longer
  validates stays visible in the editor and fails at run start with the
  message instead of disappearing.
- engine/PWA: the proactive action refuses a check.data (its result data
  is the drained-command list - the check was silently ignored); the
  vestigial _testScriptDraft is gone; berLenStr emits the 2/3-byte BER
  long form (a >255-byte C9 composes correctly).
- docs: api.md (the stop cleanup answers ok, never an automatic cancel;
  the server persists the counters - the PWA does not write them back; the
  suite bundle import always merges the scripts; the quota-0 note) and
  test-scripts.md (the event action covers all modelled events; the
  direct-create path needs a suite_id; the format history); help EN/RU
  counter wording.
- tests: the zero quota / truncated EF, the prune path, the invalid-script
  listing, the proactive data-check refusal, berLenStr and the >255-byte
  C9.  Tests: 839 frontend + 804 Python; sw cache simple-v403.
This commit is contained in:
2026-10-06 01:37:49 +03:00
parent b2cb42caf5
commit e12dfc7871
14 changed files with 196 additions and 36 deletions
+13 -8
View File
@@ -775,8 +775,10 @@ 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
`scp80_counter` back to the card preset after the run.
(`scp80_counter`) and the step list; poll `/api/test/status`. The **server
persists** every counter an SCP80 step consumed into the preset store as the
run proceeds; the snapshot's `scp80_counter`/`scp80_counters` carry the
latest values (the PWA only syncs its local copy - nothing is written back).
A run that ends (an error, a stop, an unexpected pending command) with a
command still announced **drains it with `ok` (0x00)** - fetching while the
@@ -853,9 +855,11 @@ the script endpoints, not here), `POST /api/test/suites/delete` (`{id}`)
removes it - refused while the suite still holds scripts - and
`POST /api/test/suites/import` takes
`{suites: [...], scripts: [...], mode: "merge"|"replace"}`: the scripts are
imported first (ids preserved; a collision gets a fresh uuid and the suites'
references are remapped), then the suites; an unresolvable reference is
reported in `errors` and the suite is skipped. After the import the
imported first **always with `merge`** (a script replace would wipe the other
suites' scripts; the mode applies to the suite store), ids preserved, a
collision gets a fresh uuid and the suites' references are remapped; then the
suites are imported (`replace` wipes the suite store first); an unresolvable
reference is reported in `errors` and the suite is skipped. After the import the
ownership invariant is reconciled: a script the imported suites list adopts
its listing suite, and a script no suite lists any more — the `replace` mode
wipes the suites that owned them — is **adopted into "Imported scripts"**
@@ -900,8 +904,9 @@ expected/actual pairs are reported per check.
### `POST /api/test/stop`
Requests a stop (`{"stop": true}` is set on the run); the runner finishes the
current step, answers any pending proactive command with a cancel TERMINAL
RESPONSE and reports the run as `stopped`.
current step, drains any pending proactive command with an **ok** (0x00)
TERMINAL RESPONSE (the same bounded cleanup as a run that ends - a cancel is
never sent automatically, v3.21.0) and reports the run as `stopped`.
### `POST /api/test/clear`
@@ -936,7 +941,7 @@ Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL
| `sd_aid` | no | Security Domain AID for INSTALL[for load]; empty → default ISD `A000000003000000` |
| `install_params` | no | Hex of the install-parameter field — the mandatory `C9` TLV (application-specific parameters; `C900` when empty), the System Specific Parameters `EF`, extra raw TLVs. When empty the server **composes** it from `volatile_quota`/`nv_quota` and `stk_params` (see below) |
| `stk_params` | no | The STK part appended to the install parameters: the **EF-wrapped** CA (`EF <len> CA <len> …`, TS 102 226 §8.2.1.3.2.1 — the SIM access domain leads the CA value as `01 <ad>` / `00`) or the UICC template (`EA <len> 80 … [81 …]`). A bare `CA` is refused (it belongs inside the EF) |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) coded as `C8` / `C7` in the install EF; only composed when `install_params` is empty |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) coded as `C8` / `C7` in the install EF; only composed when `install_params` is empty. `0` is a valid quota (the vendor reference form carried `C7 02 0000 C8 02 0000`); omit the field to leave the TLV out |
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
| `load_block_size` | no | Bytes of load-file payload per LOAD APDU, 1–240 (default 240 when omitted). SCP80 concatenation carries a secured packet larger than one SMS over up to 5 SMs, so the block size is no longer clamped to fit a single SMS. |
| `ram_format` | no | RAM command format: `auto` (default — the read-only probe decides, recorded as `FORMAT CHECK (...)` steps), `compact` or `expanded` to pin it |
+11 -6
View File
@@ -36,7 +36,8 @@ RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
`envelope` action's `src` override since 3.18.1, the inline `por` object, the
`alpha`/`sms` checks, `scp80.params.format` and the case-insensitive
`menu-select` text match since 3.19.0, the `proactive` cleanup action and the
ok-answering bounded cleanup since 3.21.0, the suites since 3.22.0.
ok-answering bounded cleanup since 3.21.0, the suites and the Simulator's
event forms for all modelled events since 3.22.0.
## Writing a test for an applet
@@ -158,7 +159,8 @@ PoR-in-submit:
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**.
`POST /api/test/scripts/import`; a direct `POST /api/test/scripts` needs a
`suite_id`), 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`).
@@ -348,7 +350,9 @@ next step.
- `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).
- `data` - the response data (for SCP80: the inline PoR packet). The
`proactive` action does not take a `data` check - assert the drained
commands with `require` and the final status with `sw`.
- `por` (SCP80 actions only) - `"any"` (default, no check), `"ok"`
(`response_status == por_ok`), `"none"` (no PoR expected), or an **object**
`{"status"?, "sw"?, "data"?}` asserting the decoded PoR of this very
@@ -605,6 +609,7 @@ expectation's `respond`, or the proactive action's `first`).
same format).
- `files` matches the File List as a set (order-insensitive); duplicate paths
are kept.
- Semantic `event` actions cover four events (0x03/0x0B/0x12/0x1D); every
other event uses the raw `envelope` action with the data hex from the
Phone tab's event form.
- Semantic `event` actions cover every event the Simulator models: the four
server-built events (0x03/0x0B/0x12/0x1D) from `fields`, every other event
from the editor-built `data` hex (the raw `envelope` action stays for
hand-built data).
+1 -1
View File
@@ -564,7 +564,7 @@
<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>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<p class="text-sm mb-2">У вкладки <strong>четыре представления</strong>: <strong>список наборов</strong> (по умолчанию; <em>Добавить набор</em> / <em>Редактировать</em> / <em>Запустить</em> / <em>Экспорт</em> / <em>Удалить</em>), <strong>редактор набора</strong> (скрипты набора в порядке выполнения &mdash; необязательный <em>setup</em> первым и <em>teardown</em> последним, с пометками ролей; у каждого скрипта <em>Запустить</em>/<em>Редактировать</em>/<em>Переместить</em>/<em>Скопировать</em>/<em>Удалить</em>, политика <em>при ошибке</em> <em>остановить</em> (по умолчанию) / <em>продолжить</em> и <em>Требуется ADM</em>), <strong>редактор скрипта</strong> (список шагов; кнопка <em>Старт</em> выполняет только этот скрипт и показывает результат под шагами &mdash; он не сохраняется) и <strong>отчёт набора</strong> (после запуска набора: статус каждого скрипта, таблица шагов с ожидаемым/фактическим, примечание об ошибке и выдержка из журнала запуска; сводка с успешно/предупреждения/ошибки/пропущено, время выполнения, счётчики SCP80 до&nbsp;&rarr;&nbsp;после по каждому keyset, пресет и предупреждение о смене сессии карты; экспорт в <em>JSON</em> или <em>Markdown</em>). <strong>Запуск набора</strong> выполняет его скрипты по порядку в <strong>одной сессии карты, без сбросов и без продолжения с места остановки</strong>; <em>при ошибке</em> решает, остановится ли набор на этом скрипте или запустится следующий, <strong>teardown выполняется всегда</strong> при остановке или ошибке (пока карта в ридере), чтобы следующий тест начался с чистого состояния, а сброс карты останавливает набор и отмечается в отчёте. <strong>Требуется ADM</strong> (у набора или скрипта) проверяет ключ ADM из подобранного пресета <strong>один раз</strong> перед запуском &mdash; ошибка отклоняет запуск (проверьте вручную через значок ADM; автоматических повторов нет). Скрипты хранятся в хранилище наборов (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); каждый скрипт принадлежит ровно одному набору, и скрипт можно переместить или скопировать в другой набор (экспорт набора &mdash; это пакет с набором и его скриптами; старый экспорт только скриптов импортируется в автоматически созданный набор &laquo;Imported scripts&raquo;).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: <strong>та же форма, что и в диалоге отправки на вкладке «Телефон»</strong>, для всех смоделированных событий &mdash; четыре события, которые собирает сервер (Location status, Access technology change, Network rejection, Data connection status change), передаются полями, которые он проверяет и собирает во время запуска, остальные несут собранный формой hex данных события (показывается под формой, с проверкой одного ENVELOPE); источник device identities берётся из события (например, MT call &rarr; сеть) или из его поля источника), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; выбор <em>«Учёт регистра»</em> в редакторе шагов включает точное совпадение; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска), <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду) и <em>Очистка proactive</em> (опрос STATUS и поглощение всех объявленных команд &mdash; результат TERMINAL RESPONSE для каждой, опционально другой результат для первой; граница попыток/интервала; опциональная проверка <em>require</em> по поглощённому. Пустой опрос проходит, финальный STATUS должен быть idle, а оставшаяся команда передаётся следующему шагу &mdash; используйте в начале, середине или конце скрипта, чтобы убедиться, что карта перестала слать команды). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</li>
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: <strong>та же форма, что и в диалоге отправки на вкладке «Телефон»</strong>, для всех смоделированных событий &mdash; четыре события, которые собирает сервер (Location status, Access technology change, Network rejection, Data connection status change), передаются полями, которые он проверяет и собирает во время запуска, остальные несут собранный формой hex данных события (показывается под формой, с проверкой одного ENVELOPE); источник device identities берётся из события (например, MT call &rarr; сеть) или из его поля источника), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; выбор <em>«Учёт регистра»</em> в редакторе шагов включает точное совпадение; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а сервер сохраняет потреблённый счётчик в пресет по ходу запуска), <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду) и <em>Очистка proactive</em> (опрос STATUS и поглощение всех объявленных команд &mdash; результат TERMINAL RESPONSE для каждой, опционально другой результат для первой; граница попыток/интервала; опциональная проверка <em>require</em> по поглощённому. Пустой опрос проходит, финальный STATUS должен быть idle, а оставшаяся команда передаётся следующему шагу &mdash; используйте в начале, середине или конце скрипта, чтобы убедиться, что карта перестала слать команды). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</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; Ошибка останавливает скрипт, Предупреждение продолжает (терпимая форма &laquo;поглотить команду, если она есть&raquo;) &mdash; а действие STATUS с <em>попытками</em> &gt; 1 опрашивает, когда апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные &mdash; для собственного SMS апплета), проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80 &mdash; SW R-APDU находится <em>внутри</em> полезной нагрузки ответа: собственный TAR апплета возвращает данные приложения, поэтому проверяйте данные, а SW оставляйте пустым; отклонённый ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) может вовсе не нести пакет ответа &mdash; для такого шага оставляйте проверку PoR «любой» или ожидайте «без PoR») и проверка <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>; редактор шагов — формный (действие Event показывает форму события из симулятора), а набор экспортируется/импортируется пакетом JSON (набор и его скрипты).</li>
+1 -1
View File
@@ -563,7 +563,7 @@
<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; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order &mdash; the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps &mdash; not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before&nbsp;&rarr;&nbsp;after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start &mdash; a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created &ldquo;Imported scripts&rdquo; suite).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event &mdash; the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call &rarr; network) or its source field), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the step editor's <em>Match case</em> choice switches to an exact-case match; 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 and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; 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), <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command) and <em>Proactive cleanup</em> (poll STATUS and consume every announced command &mdash; the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step &mdash; use it at the start, in the middle or at the end of a script to make sure the card has stopped sending). 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>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event &mdash; the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call &rarr; network) or its source field), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the step editor's <em>Match case</em> choice switches to an exact-case match; 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 and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; 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; the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command) and <em>Proactive cleanup</em> (poll STATUS and consume every announced command &mdash; the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step &mdash; use it at the start, in the middle or at the end of a script to make sure the card has stopped sending). 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>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</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;: the step fails at its fail level &mdash; Error stops the script, Warning continues (the tolerant &ldquo;consume a command if there is one&rdquo; form) &mdash; and a STATUS action with <em>attempts</em> &gt; 1 polls when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data &mdash; for an applet's own SMS), 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 &mdash; the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all &mdash; leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) 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); every step failure honours it, including an expectation that finds no pending command; 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 (the event action renders the Simulator's event form), and a suite exports/imports as a JSON bundle (the suite plus its scripts).</li>
+15 -6
View File
@@ -5760,7 +5760,9 @@ function genBerPcValue(row, tag) {
function berLenStr(n) {
if (n < 128) return n.toString(16).padStart(2,'0').toUpperCase();
return '81' + n.toString(16).padStart(2,'0').toUpperCase();
if (n < 256) return '81' + n.toString(16).padStart(2,'0').toUpperCase();
if (n < 65536) return '82' + n.toString(16).padStart(4,'0').toUpperCase();
return '83' + n.toString(16).padStart(6,'0').toUpperCase();
}
function genBerTlv() {
@@ -6945,7 +6947,6 @@ let _testSuites = null; // the suite store cache
let _testView = 'suites'; // suites | suite | script | report
let _testSuiteIdx = -1; // the suite being edited (index into _testSuites)
let _testScriptId = null; // the script being edited (store id)
let _testScriptDraft = null; // a new, not-yet-saved script
let _testRunState = null;
let _testRunScriptRef = null; // the script object a single run belongs to
let _testRunTimer = null;
@@ -7019,7 +7020,7 @@ function testScripts() { return _testScripts || []; }
function testSuiteById(id) { return testSuites().find(s => s.id === id) || null; }
function testScriptById(id) { return testScripts().find(s => s.id === id) || null; }
function testCurrentSuite() { return testSuites()[_testSuiteIdx] || null; }
function testCurrentScript() { return _testScriptDraft || testScriptById(_testScriptId); }
function testCurrentScript() { return testScriptById(_testScriptId); }
function testSuiteEntry(i) {
const s = testCurrentSuite();
return s ? ((s.scripts || [])[i] || null) : null;
@@ -7064,7 +7065,6 @@ async function testScriptsWriteback(script) {
if (resp.script && resp.script.id) {
script.id = resp.script.id;
_testScriptId = script.id;
_testScriptDraft = null;
const list = testScripts();
if (!list.some(s => s.id === script.id)) list.push(resp.script);
await testSuitesRefetch(); // the suite gained the member
@@ -7500,9 +7500,19 @@ async function testSuiteDeleteScript(i) {
if (!s || !e) return;
if (!confirm(t('Delete this test script?'))) return;
const script = testScriptById(e.script_id);
if (!script) {
// The reference is already dangling (the script is gone from the
// store): no script endpoint can reach it, so the row's Delete prunes
// the member entry - the one member-set change the suite update
// accepts (v3.22.0 review) - and saves the suite.
s.scripts.splice(i, 1);
testSuiteSave(true);
testRenderSuiteScripts();
return;
}
// A pending debounced save must not resurrect the script: cancel it and
// mark the object (a queued writeback checks the flag when it runs).
if (script && script === testCurrentScript()) {
if (script === testCurrentScript()) {
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
script._deleted = true;
}
@@ -7577,7 +7587,6 @@ async function testSuiteTargetGo(i) {
function testOpenScript(id, run) {
_testScriptId = id;
_testScriptDraft = null;
_testSuiteTarget = null;
testViewShow('script');
if (run) testRunStart();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v402';
const CACHE = 'simple-v403';
const URLS = [
'index.html',
'help.html',
+10
View File
@@ -314,6 +314,16 @@ test('genBerRowValue wraps a C-APDU row in the 22 command TLV', () => {
assert.strictEqual(genBerRowValue(cmdRow('hex', '')), '');
});
test('berLenStr uses the BER long form for 2- and 3-byte lengths', () => {
assert.strictEqual(berLenStr(0), '00');
assert.strictEqual(berLenStr(127), '7F');
assert.strictEqual(berLenStr(128), '8180');
assert.strictEqual(berLenStr(255), '81FF');
assert.strictEqual(berLenStr(256), '820100');
assert.strictEqual(berLenStr(65535), '82FFFF');
assert.strictEqual(berLenStr(65536), '83010000');
});
test('the embedded pickers offer the RFM/RAM command sets without GET RESPONSE', () => {
assert.ok(chainCommands('ber-sim-1').some(c => c.value === 'select'));
assert.ok(chainCommands('ber-usim-1').some(c => c.value === 'create-file'));
+4
View File
@@ -147,6 +147,10 @@ test('composeRamInstallParams composes C9 + EF (C7/C8) like the vendor scripts',
// extra raw TLVs follow the generated prefix
assert.strictEqual(composeRamInstallParams('', '', '', 'CB02AABB', '').install_params,
'C900CB02AABB');
// a C9 above 255 bytes uses the 2-byte BER length (0x12C = 300)
const longC9 = 'AA'.repeat(300);
assert.strictEqual(composeRamInstallParams(longC9, '', '', '', '').install_params,
'C982012C' + longC9);
});
test('an EF-form STK part takes the quotas into its own EF (one EF, TS 102 226 8.2.1.3.2.1)', () => {
+41 -13
View File
@@ -744,6 +744,14 @@ def _single_ef_value(hex_str):
'(TS 102 226 8.2.1.3.2.1): send EF <len> CA ...')
if data[0] != 0xEF:
return None
if len(data) < 2:
raise ValueError('stk_params: malformed EF TLV (the length byte is '
'missing)')
first = data[1]
if first == 0x80:
raise ValueError('stk_params: malformed EF TLV (indefinite length)')
if first >= 0x80 and len(data) < 2 + (first & 0x7F):
raise ValueError('stk_params: malformed EF TLV (truncated length)')
ln, off = _ber_len_at(data, 1)
if off + ln != len(data):
raise ValueError('stk_params: malformed EF TLV (the length does not '
@@ -768,6 +776,14 @@ def _quota_tlv(tag, value):
return tag + ('%02X%04X' % (2, v) if v <= 0x7FFF else '%02X%08X' % (4, v))
def _quota_provided(value):
"""Whether a memory-quota field was given: ``None`` and blank strings
mean "not requested", while ``0`` is a valid quota (the vendor reference
form carried ``C7 02 0000 C8 02 0000``), so an integral zero must be
composed, not dropped."""
return value is not None and str(value).strip() != ''
def _compose_install_params(install_params, stk_params, nv_quota=None,
volatile_quota=None):
"""The INSTALL [for install] parameter field, with the composition the
@@ -790,8 +806,7 @@ def _compose_install_params(install_params, stk_params, nv_quota=None,
"""
raw = (install_params or '').replace(' ', '')
stk = (stk_params or '').replace(' ', '')
has_quota = (str(nv_quota or '').strip() != ''
or str(volatile_quota or '').strip() != '')
has_quota = _quota_provided(nv_quota) or _quota_provided(volatile_quota)
stk_ef = _single_ef_value(stk)
if raw:
if has_quota:
@@ -812,9 +827,9 @@ def _compose_install_params(install_params, stk_params, nv_quota=None,
return (raw + stk).upper()
# Compose from the primitives: C9 is mandatory (GP Table 11-49).
inner = ''
if str(volatile_quota or '').strip() != '':
if _quota_provided(volatile_quota):
inner += _quota_tlv('C7', volatile_quota)
if str(nv_quota or '').strip() != '':
if _quota_provided(nv_quota):
inner += _quota_tlv('C8', nv_quota)
if stk_ef is not None:
inner += stk_ef
@@ -6050,9 +6065,14 @@ def reconcile_scripts_to_suites(script_store, suite_store):
try:
script_store.update(s['id'], {'suite_id': suite['id']})
except (test_scripts.TestScriptError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot attach %s to "%s": %s\n'
# The member list is authoritative: list the script anyway. The
# store keeps a script that no longer validates (a tightened rule
# must not destroy it) and its suite_id write is refused by the
# same validator - without the listing the script would be
# invisible in the PWA and could never be fixed in the editor.
sys.stderr.write('TESTSUITES: cannot set the suite id of %s '
'(listed in "%s" so the editor can fix it): %s\n'
% (s['id'], suite['name'], e))
continue
if s['id'] not in known:
entries.append({'script_id': s['id'], 'role': 'member', 'on_fail': 'stop'})
known.add(s['id'])
@@ -9726,15 +9746,23 @@ class PysimHandler(BaseHTTPRequestHandler):
if 'scripts' in (fields or {}):
old_ids = [e['script_id'] for e in cur['scripts']]
new = fields['scripts'] if isinstance(fields['scripts'], list) else []
new_ids = [str((e or {}).get('script_id') or '').lower()
new_ids = [str((e or {}).get('script_id') or '').strip().lower()
for e in new if isinstance(e, dict)]
if set(new_ids) != set(old_ids):
resp = {'error': 'the member set changes through the script '
'endpoints (add/move/delete), not by '
'editing the suite'}
self._send_json(resp, 400)
self._log_resp(resp)
return
# The member set changes through the script endpoints
# (add/move/delete) - with one repair exception: a
# reference whose script is already gone (the store keeps
# such a suite servable) can be pruned here, since no
# script endpoint can reach it any more.
removed = set(old_ids) - set(new_ids)
added = set(new_ids) - set(old_ids)
if added or any(scripts.get(sid) is not None for sid in removed):
resp = {'error': 'the member set changes through the script '
'endpoints (add/move/delete), not by '
'editing the suite'}
self._send_json(resp, 400)
self._log_resp(resp)
return
err = _test_suite_refs_error(scripts, fields)
if err:
resp = {'error': err}
+7
View File
@@ -398,6 +398,13 @@ def _normalise_check(check, kind, params):
else:
sw = {'mode': 'exact', 'value': '9000'}
data = _check_spec(check.get('data'), 'check.data')
if kind == 'proactive' and data is not None:
# The proactive cleanup's result data is the drained command list, not
# a single response: a `data` check would be silently ignored, so it
# is refused here (assert the drained commands with `require`).
raise ScriptError('check.data is not used by the proactive action - '
'assert what was drained with require, or the final '
'status word with check.sw')
por = check.get('por')
if isinstance(por, dict):
# The decoded-PoR assertion (available on scp80 actions whether the
+30
View File
@@ -1330,6 +1330,36 @@ class TestScriptStoreHttpTests(unittest.TestCase):
self.assertEqual(status, 400, resp)
self.assertIn('member set changes through the script endpoints', resp['error'])
def test_suite_update_prunes_a_dangling_reference(self):
# The store keeps a suite whose script is gone (servable by design);
# pruning the member entry is the one member-set change the update
# endpoint accepts - no script endpoint can reach it any more.
suite = self._suite(name='stale')
self.suites.update(suite['id'], {'scripts': [
{'script_id': 'f' * 32, 'role': 'member', 'on_fail': 'stop'}]})
# the full list (with the dangling id) is still refused...
status, resp = self._post('/api/test/suites/update', {
'id': suite['id'], 'suite': {'name': 'renamed', 'scripts': [
{'script_id': 'f' * 32, 'role': 'member', 'on_fail': 'stop'}]}})
self.assertEqual(status, 400, resp)
self.assertIn('unknown test script', resp['error'])
# ...but the prune is accepted, and the suite is editable again
status, resp = self._post('/api/test/suites/update', {
'id': suite['id'], 'suite': {'scripts': []}})
self.assertEqual(status, 200, resp)
self.assertEqual(resp['suite']['scripts'], [])
status, resp = self._post('/api/test/suites/update', {
'id': suite['id'], 'suite': {'name': 'renamed'}})
self.assertEqual(status, 200, resp)
self.assertEqual(resp['suite']['name'], 'renamed')
# removing a script that still exists stays the script endpoint's job
status, resp = self._post('/api/test/scripts', self._script(suite_id=suite['id']))
self.assertEqual(status, 200, resp)
status, resp = self._post('/api/test/suites/update', {
'id': suite['id'], 'suite': {'scripts': []}})
self.assertEqual(status, 400, resp)
self.assertIn('member set changes', resp['error'])
def test_move_and_copy_between_suites(self):
a = self._suite(name='a')
b = self._suite(name='b')
+24
View File
@@ -1612,6 +1612,30 @@ class CapApduSequenceTest(unittest.TestCase):
self.assertEqual(_compose_install_params('C900EF04C7020064', 'EA0480000000'),
'C900EF04C7020064EA0480000000')
def test_install_params_zero_quota_and_truncated_ef(self):
# `0` is a valid quota (the vendor reference form carried
# `C7 02 0000 C8 02 0000`): only an absent/empty field is unset.
from pysim_simple_server.server import (_compose_install_params,
_single_ef_value)
self.assertEqual(_compose_install_params('', '', nv_quota=0),
'C900EF04C8020000')
self.assertEqual(_compose_install_params('', '', volatile_quota=0,
nv_quota=0),
'C900EF08C7020000C8020000')
self.assertEqual(_compose_install_params('', '', nv_quota='0'),
'C900EF04C8020000')
self.assertEqual(_compose_install_params('', '', nv_quota=0.0),
'C900EF04C8020000')
# an absent/empty field is not composed
self.assertEqual(_compose_install_params('', '', nv_quota='',
volatile_quota=None), 'C900')
# a truncated EF length is malformed, not an empty EF
for bad in ('EF', 'EF81', 'EF80', 'EF8201'):
with self.assertRaises(ValueError, msg=bad):
_single_ef_value(bad)
# an empty EF (EF 00) is legal
self.assertEqual(_single_ef_value('EF00'), '')
def test_install_apdu_carries_the_composed_parameters(self):
from pysim_simple_server.server import _cap_install_apdu
# CA-form STK + quotas: `C9 00 EF{C7,C8,CA}` - one EF (the form the
+27
View File
@@ -222,6 +222,33 @@ class MigrationTests(unittest.TestCase):
# idempotent: nothing left to attach
self.assertEqual(srv.migrate_scripts_to_suites(scripts, suites), 0)
def test_an_invalid_script_is_still_listed_by_the_reconciliation(self):
# The script store keeps an entry that no longer validates (a
# tightened rule must not destroy it). Its suite_id write is refused
# by the same validator, but the member list is authoritative: the
# script must be listed, or it would be invisible in the PWA and
# could never be fixed in the editor.
from pysim_simple_server import server as srv
from pysim_simple_server import test_scripts
with tempfile.TemporaryDirectory() as tmp:
path = pathlib.Path(tmp) / 's.json'
step = {'type': 'action', 'kind': 'status', 'params': {'attempts': 1}}
scripts = test_scripts.TestScriptStore(path) # permissive
script = scripts.add({'name': 'stale', 'steps': [step]})
def validator(raw):
raise ValueError('the rule tightened')
strict = test_scripts.TestScriptStore(path, validator=validator)
suites = test_suites.TestSuiteStore(pathlib.Path(tmp) / 'u.json')
self.assertEqual(srv.reconcile_scripts_to_suites(strict, suites), 1)
imported = suites.find_by_name('Imported scripts')
self.assertEqual([e['script_id'] for e in imported['scripts']],
[script['id']])
# the suite_id write could not go through - the listing is what
# keeps the script visible and fixable
self.assertIsNone(strict.get(script['id'])['suite_id'])
def test_the_reconciliation_adopts_listed_and_missing_suite_scripts(self):
# The general invariant repair: a script whose suite_id points at a
# missing suite (a suites import with mode replace) joins "Imported
+11
View File
@@ -119,6 +119,17 @@ class TestValidation(unittest.TestCase):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'proactive',
'params': bad}]}, _resolver)
def test_the_proactive_action_refuses_a_data_check(self):
# The proactive result's data is the drained-command list, not a
# single response: a `data` check would be silently ignored, so the
# engine refuses it (assert what was drained with `require`).
with self.assertRaises(T.ScriptError) as cm:
T.normalise_script({'steps': [
{'type': 'action', 'kind': 'proactive',
'check': {'data': 'AABB'}}]}, _resolver)
self.assertIn('check.data is not used by the proactive action',
str(cm.exception))
def test_integer_fields_accept_decimals_with_leading_zeros_and_hex(self):
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'status',