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:
+15
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 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> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения TAR/SPI1/SPI2; KIc/KID и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты, а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
||||
<li><strong>Ожидания</strong> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое (текст / пункт / raw), а <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
|
||||
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
||||
<li><strong>Ожидания</strong> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие 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> — несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
|
||||
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
|
||||
</ul>
|
||||
|
||||
+2
-2
@@ -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 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> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1–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> > 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 — <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> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command” and is an error — add a STATUS action when the applet delivers on poll. The command type, qualifier and contents (text / item / raw checks) are verified, and the scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
|
||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (item id 1–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 — the run names the stored preset, so it always uses its keysets, TARs and counter — and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> > 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 — <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> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command” and is an error — add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, a <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> — 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> — 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
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v377';
|
||||
const CACHE = 'simple-v378';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user