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

Three gaps blocked SCP80 applet testing from the PWA:

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

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