v3.19.0: the applet-agent script batch - por objects, alpha/sms checks, scp80 format
The test scripts get the pieces an applet test needed:
- scp80 check.por accepts an object {status?, sw?, data?} asserting the
decoded PoR of this very exchange (inline or via SEND SHORT MESSAGE);
the evaluation is shared with the expect-step por check
(_por_check_results). The editor's PoR select gains 'custom' with the
three fields (an empty object refuses on save).
- a new alpha content check reads the command's Alpha identifier (05/85,
Annex A coded) - the text check stays bound to the 8D Text string, so a
command carrying both keeps them distinct.
- a new sms content check asserts a SEND SHORT MESSAGE's submit TPDU:
DA / PID / DCS / UDL / UD (the parsed fields are logged and reported;
an applet's own SMS, not the OTA PoR the por check decodes).
- scp80.params.format wraps the C-APDU in the TS 102 226 5.2.1 Command
Scripting template (expanded = AA..., expanded-ae = AE 80...00 00); the
log shows WRAPPED=, the report C-APDU=<effective> format=<fmt>.
- docs: Worksheet 1 addresses an RFM TAR (B00000/B00001/B00010 compact,
B00120/B00140/B00130 expanded) with the own-TAR and MSL/spi1 notes, and
file-write is documented as a direct APDU that does not fire
EVENT_REMOTE_FILE_UPDATE.
Tests: 819 frontend + 724 Python; sw cache simple-v387.
This commit is contained in:
+14
-6
@@ -677,25 +677,33 @@ SET UP MENU, which is refreshed whenever the card sends the menu), `file-write`
|
|||||||
verifies `check.data`),
|
verifies `check.data`),
|
||||||
`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by
|
`apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by
|
||||||
the optional `source` field when both are present; optional `kvn` (keyset
|
the optional `source` field when both are present; optional `kvn` (keyset
|
||||||
number 1-15), `tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always
|
number 1-15), `tar`/`spi1`/`spi2` overrides and `format` (`compact` default /
|
||||||
|
`expanded` / `expanded-ae` - the TS 102 226 5.2.1 Command Scripting template
|
||||||
|
around the C-APDU) - KIc/KID and the counter always
|
||||||
come from the resolved preset, which must match the equipped card and be
|
come from the resolved preset, which must match the equipped card and be
|
||||||
complete) or `status`
|
complete) or `status`
|
||||||
(`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the
|
(`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the
|
||||||
mask `91??`, i.e. poll until the card announces a command).
|
mask `91??`, i.e. poll until the card announces a command).
|
||||||
|
|
||||||
`check` is `{"sw": ..., "data": ...}` (exact or `{"mode": "mask", "value":
|
`check` is `{"sw": ..., "data": ...}` (exact or `{"mode": "mask", "value":
|
||||||
"91??"}`, `?` = per-nibble wildcard) plus `"por": "none"|"ok"|"any"` for
|
"91??"}`, `?` = per-nibble wildcard) plus `"por"` for SCP80 -
|
||||||
SCP80. `on_fail` is `error` (terminates the script) or `warning` (continues).
|
`"none"`/`"ok"`/`"any"` or an object `{"status"?, "sw"?, "data"?}` asserting
|
||||||
|
the decoded PoR of this exchange (inline or SEND SHORT MESSAGE; at least one
|
||||||
|
field). `on_fail` is `error` (terminates the script) or `warning` (continues).
|
||||||
|
|
||||||
**Expectation steps** (`type: "expect"`) require a command pending from the
|
**Expectation steps** (`type: "expect"`) require a command pending from the
|
||||||
previous step (`91XX`); they never poll - a `9000` response means no command
|
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
|
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`
|
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
|
may be `text` (contains/exact), `alpha` (the command's Alpha identifier
|
||||||
MENU), `raw` (mask), `por` (a SEND SHORT MESSAGE carrying the PoR of the
|
`05`/`85`, contains/exact - distinct from the `8D` text string), `item`
|
||||||
|
(`id`/`text` for SELECT ITEM / SET UP 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
|
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
|
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
|
`data` (the R-APDU response data, exact/mask)), `sms` (the SEND SHORT MESSAGE
|
||||||
|
TPDU: `da` digits, `pid`/`dcs`/`ud` hex exact/mask, `udl` decimal; SMS-SUBMIT
|
||||||
|
only) or `files` (the File List of a
|
||||||
REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched
|
REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched
|
||||||
order-insensitively); `respond` is the TERMINAL RESPONSE in the TS 102 223
|
order-insensitively); `respond` is the TERMINAL RESPONSE in the TS 102 223
|
||||||
6.8.0 object order (`result` name or value; `text`+`dcs` for GET INKEY/GET
|
6.8.0 object order (`result` name or value; `text`+`dcs` for GET INKEY/GET
|
||||||
|
|||||||
+44
-9
@@ -14,8 +14,9 @@ localStorage export imports as-is), and any API client can create or run them
|
|||||||
|
|
||||||
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
|
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
|
||||||
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
|
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
|
||||||
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0 and
|
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
|
||||||
the `envelope` action's `src` override since 3.18.1.
|
`envelope` action's `src` override since 3.18.1, the inline `por` object, the
|
||||||
|
`alpha`/`sms` checks and `scp80.params.format` since 3.19.0.
|
||||||
|
|
||||||
## Writing a test for an applet
|
## Writing a test for an applet
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ and the card's preset):
|
|||||||
|
|
||||||
| What | Where it goes | Notes |
|
| What | Where it goes | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Applet TAR (3 bytes) + MSL, or an explicit SPI1 | `scp80.params.tar` / `spi1` | the card preset must carry the TAR with its MSL, or the step passes `spi1` |
|
| TAR to address (the RFM application or the applet's own) + MSL, or an explicit SPI1 | `scp80.params.tar` / `spi1` | the card preset must carry the TAR with its MSL, or the step passes `spi1`; RFM TARs: `B00000`/`B00001`/`B00010` compact, `B00120`/`B00140`/`B00130` expanded (see Worksheet 1) |
|
||||||
| Keyset number | `scp80.params.kvn` (1–15) | only when the applet's keys are not the preset's first keyset |
|
| Keyset number | `scp80.params.kvn` (1–15) | only when the applet's keys are not the preset's first keyset |
|
||||||
| Trigger C-APDUs | `scp80.params.apdu` | one secured packet per `scp80` step; the preset provides keys and counter |
|
| Trigger C-APDUs (+ their format) | `scp80.params.apdu` / `format` | one secured packet per `scp80` step; the preset provides keys and counter, `format` wraps the APDU for an expanded-format application |
|
||||||
| Expected file paths + contents | `file-read.params.path` + `check.data` | a mask must have **exactly** the actual value's length |
|
| Expected file paths + contents | `file-read.params.path` + `check.data` | a mask must have **exactly** the actual value's length |
|
||||||
| Expected proactive command(s) | `expect.command` + `qualifier`/`checks` | REFRESH `qualifier: "00"` = NAA init + full FCN, `files` asserts the changed EFs |
|
| Expected proactive command(s) | `expect.command` + `qualifier`/`checks` | REFRESH `qualifier: "00"` = NAA init + full FCN, `files` asserts the changed EFs |
|
||||||
| PoR transport + expected PoR | `scp80.check.por`/`check.data` (inline) or `expect SEND SHORT MESSAGE` + `por` check | pick per the SPI2 the applet expects |
|
| PoR transport + expected PoR | `scp80.check.por`/`check.data` (inline) or `expect SEND SHORT MESSAGE` + `por` check | pick per the SPI2 the applet expects |
|
||||||
@@ -38,7 +39,7 @@ and the card's preset):
|
|||||||
{"name": "applet RFM update",
|
{"name": "applet RFM update",
|
||||||
"steps": [
|
"steps": [
|
||||||
{"type": "action", "kind": "scp80",
|
{"type": "action", "kind": "scp80",
|
||||||
"params": {"apdu": "<RFM C-APDU>", "tar": "<applet TAR>", "kvn": 1, "spi2": "01"},
|
"params": {"apdu": "<RFM C-APDU>", "tar": "B00001", "kvn": 1, "spi2": "01"},
|
||||||
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
|
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
|
||||||
"label": "RFM update"},
|
"label": "RFM update"},
|
||||||
{"type": "expect", "command": "REFRESH", "qualifier": "00",
|
{"type": "expect", "command": "REFRESH", "qualifier": "00",
|
||||||
@@ -49,6 +50,17 @@ and the card's preset):
|
|||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tar` addresses the **RFM application that owns the file system**, not the
|
||||||
|
applet-under-test: `B00000` = UICC shared FS, `B00001` = ADF RFM, `B00010` =
|
||||||
|
SIM FS (compact), and `B00120`/`B00140`/`B00130` the expanded-format twins
|
||||||
|
(TS 101 220 Annex D; the probe list in the SCP80 tab carries the same names).
|
||||||
|
An applet that accepts secured packets under its **own** TAR - the TAR given
|
||||||
|
in its INSTALL [for install] parameters (TS 102 226 §8.2.1.3.2.7) - uses that
|
||||||
|
TAR instead. The preset must carry a TAR entry **with its MSL** or the step
|
||||||
|
passes an explicit `spi1`; an expanded-format RFM application also expects the
|
||||||
|
wrapped command template (`"format": "expanded"`/`"expanded-ae"`, see the
|
||||||
|
actions table).
|
||||||
|
|
||||||
### Worksheet 2 - incoming data → particular PoR, no other actions
|
### Worksheet 2 - incoming data → particular PoR, no other actions
|
||||||
|
|
||||||
Inline PoR:
|
Inline PoR:
|
||||||
@@ -113,6 +125,9 @@ TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One'
|
|||||||
|
|
||||||
- A PoR delivered as SEND SHORT MESSAGE is logged on the step that fetches it:
|
- A PoR delivered as SEND SHORT MESSAGE is logged on the step that fetches it:
|
||||||
`PoR[sms-submit] …` followed by the same `R-APDU` line.
|
`PoR[sms-submit] …` followed by the same `R-APDU` line.
|
||||||
|
- A SEND SHORT MESSAGE expectation also logs its parsed TPDU
|
||||||
|
(`SMS DA=… PID=… DCS=… UDL=… UD=…`) after the `CMD` line, so an applet's own
|
||||||
|
SMS can be read even without an `sms` check.
|
||||||
- Other actions log their send/response pair too (`APDU TX=… -> SW=…`,
|
- Other actions log their send/response pair too (`APDU TX=… -> SW=…`,
|
||||||
`ENVELOPE(Event Download) … -> SW=…`, `READ MF/… -> SW=…`).
|
`ENVELOPE(Event Download) … -> SW=…`, `READ MF/… -> SW=…`).
|
||||||
- The shared secured-packet sender logs each segment's answer
|
- The shared secured-packet sender logs each segment's answer
|
||||||
@@ -166,12 +181,17 @@ continue correctly.
|
|||||||
| `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD |
|
| `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` |
|
| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` |
|
||||||
| `apdu` | `apdu` hex | raw transport, no auto-handler |
|
| `apdu` | `apdu` hex | raw transport, no auto-handler |
|
||||||
| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2` | secured packet to the TAR |
|
| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2`, `format` | secured packet to the TAR; `format`: `compact` (default - the C-APDU verbatim), `expanded` (the `AA`-prefixed Command Scripting template) or `expanded-ae` (the `AE 80 … 00 00` form), TS 102 226 §5.2.1 |
|
||||||
| `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) |
|
| `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
|
`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`.
|
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
|
||||||
|
|
||||||
|
`file-write` sends a direct terminal-side UPDATE BINARY/RECORD - it does **not**
|
||||||
|
fire the card's EVENT_REMOTE_FILE_UPDATE (that event reports an update done by
|
||||||
|
a remote entity through OTA/RFM, TS 102 223 §7.5.9): assert the change with
|
||||||
|
`file-read` or the REFRESH `files` check.
|
||||||
|
|
||||||
### Semantic events (`kind: "event"`)
|
### Semantic events (`kind: "event"`)
|
||||||
|
|
||||||
The server builds the event objects for the events a test needs most; an
|
The server builds the event objects for the events a test needs most; an
|
||||||
@@ -208,7 +228,11 @@ the default is the terminal, matching the Phone tab's forms.
|
|||||||
`attempts > 1` defaults to the `91??` mask).
|
`attempts > 1` defaults to the `91??` mask).
|
||||||
- `data` - the response data (for SCP80: the inline PoR packet).
|
- `data` - the response data (for SCP80: the inline PoR packet).
|
||||||
- `por` (SCP80 actions only) - `"any"` (default, no check), `"ok"`
|
- `por` (SCP80 actions only) - `"any"` (default, no check), `"ok"`
|
||||||
(`response_status == por_ok`), `"none"` (no PoR expected).
|
(`response_status == por_ok`), `"none"` (no PoR expected), or an **object**
|
||||||
|
`{"status"?, "sw"?, "data"?}` asserting the decoded PoR of this very
|
||||||
|
exchange (at least one field; the same fields as the expectation's `por`
|
||||||
|
check) - it works for both the inline and the SEND SHORT MESSAGE transport.
|
||||||
|
Example: `"por": {"status": "por_ok", "sw": "9000"}`.
|
||||||
- A plain hex string is exact; a value containing `?` is a mask. **A mask
|
- 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
|
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
|
wildcard) - e.g. `91??` matches `9102`, `027100????` matches a 4-byte
|
||||||
@@ -232,10 +256,12 @@ command-details byte 3 (one byte, exact/mask).
|
|||||||
|
|
||||||
| kind | fields | checks |
|
| kind | fields | checks |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `text` | `mode` contains/exact, `value`, `case_sensitive` | alpha identifier / text |
|
| `text` | `mode` contains/exact, `value`, `case_sensitive` | the **Text string** (`8D`, DCS coded) of DISPLAY TEXT / GET INKEY / GET INPUT |
|
||||||
|
| `alpha` | `mode` contains/exact, `value`, `case_sensitive` | the command's **Alpha identifier** (tag `05`/`85`, TS 102 223 §8.2, Annex A coded) - distinct from `text` when a command carries both |
|
||||||
| `item` | `id`, `text`, `mode`, `case_sensitive` | items of SELECT ITEM / SET UP MENU |
|
| `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) |
|
| `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) |
|
| `por` | `status`, `sw`, `data` | the PoR carried by a **SEND SHORT MESSAGE** (see below) |
|
||||||
|
| `sms` | `da`, `pid`, `dcs`, `udl`, `ud` | the TPDU of 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 |
|
| `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
|
`por` needs an SCP80 step **before** the expectation: the TPDU of the SEND
|
||||||
@@ -248,6 +274,14 @@ several SMS parts is accumulated across SEND SHORT MESSAGE expectations: put
|
|||||||
the `por` check on the step that completes the sequence (intermediate steps
|
the `por` check on the step that completes the sequence (intermediate steps
|
||||||
should use `raw` or no PoR check).
|
should use `raw` or no PoR check).
|
||||||
|
|
||||||
|
`sms` parses the fetched SEND SHORT MESSAGE's TPDU (SMS-SUBMIT only - any other
|
||||||
|
MTI fails the check): `da` the destination digits (exact, whitespace ignored),
|
||||||
|
`pid`/`dcs` single bytes and `ud` the raw user data (a UDH included) as hex
|
||||||
|
exact/mask, `udl` the user-data length in decimal (octets, or septets for the
|
||||||
|
7-bit alphabet - whatever the TPDU carries). Use it for an applet's own SMS;
|
||||||
|
the OTA PoR of the preceding scp80 step is what `por` decodes. At least one
|
||||||
|
field is required.
|
||||||
|
|
||||||
### `respond`
|
### `respond`
|
||||||
|
|
||||||
The TERMINAL RESPONSE for the fetched command, in the TS 102 223 §6.8.0
|
The TERMINAL RESPONSE for the fetched command, in the TS 102 223 §6.8.0
|
||||||
@@ -275,7 +309,8 @@ UCS2 answer to a GET INPUT:
|
|||||||
|
|
||||||
1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the
|
1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the
|
||||||
response data. Verify on the action: `"por": "ok"` and/or
|
response data. Verify on the action: `"por": "ok"` and/or
|
||||||
`"data": {"mode": "mask", "value": "…"}`.
|
`"data": {"mode": "mask", "value": "…"}`, or assert the decoded PoR with
|
||||||
|
`"por": {"status": "por_ok", "sw": "9000"}`.
|
||||||
2. **PoR-in-submit** (SPI2 bit `20`, e.g. `spi2: "21"`) - the card announces
|
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:
|
a **SEND SHORT MESSAGE** (`91XX`); the PoR is inside its SMS TPDU:
|
||||||
|
|
||||||
|
|||||||
@@ -563,8 +563,8 @@
|
|||||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
|
<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>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки «Сохранить» нет — черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
|
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки «Сохранить» нет — черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника — 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change — поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <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> — <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника — 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change — поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <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 и формат команды — компактный или обёрнутый в шаблон Command Scripting TS 102 226 §5.2.1 <em>expanded</em> (AA) / <em>expanded AE 80</em>; ключи и счётчик берутся из пресета карты, подобранного по 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> или своя проверка декодированного PoR — статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</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> — карта должна объявить проактивную команду на предыдущем шаге (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>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> — отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные — для собственного SMS апплета), проверка <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> — несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
|
||||||
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li>
|
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+2
-2
@@ -562,8 +562,8 @@
|
|||||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
<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 the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
|
<p class="text-sm mb-2">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 the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change — the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match — the text is resolved against the card's menu, which follows the applet's install parameters), <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>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change — the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match — the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format — compact, or the TS 102 226 §5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE 80</em> around the C-APDU; 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>, or a custom assertion of the decoded PoR — status / SW / data; the SEND SHORT MESSAGE transport works the same).</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>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, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> — distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data — for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) 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>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 (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li>
|
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+88
-13
@@ -1877,7 +1877,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.18.4';
|
const SIMPLE_VERSION = '3.19.0';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -7040,9 +7040,18 @@ function testScriptProblem(script) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testPorSpecSummary(por) {
|
||||||
|
const parts = [];
|
||||||
|
if (por.status) parts.push('status ' + por.status);
|
||||||
|
if (por.sw) parts.push('SW ' + (por.sw.mode === 'mask' ? '~' : '') + por.sw.value);
|
||||||
|
if (por.data) parts.push('data ' + (por.data.mode === 'mask' ? '~' : '') + por.data.value);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function testCheckSummary(c) {
|
function testCheckSummary(c) {
|
||||||
if (!c || !c.kind) return '';
|
if (!c || !c.kind) return '';
|
||||||
if (c.kind === 'text') return 'text ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || '');
|
if (c.kind === 'text') return 'text ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || '');
|
||||||
|
if (c.kind === 'alpha') return 'alpha ' + (c.mode === 'exact' ? '=' : '~') + JSON.stringify(c.value || '');
|
||||||
if (c.kind === 'item') {
|
if (c.kind === 'item') {
|
||||||
let s = 'item';
|
let s = 'item';
|
||||||
if (c.id) s += ' #' + c.id;
|
if (c.id) s += ' #' + c.id;
|
||||||
@@ -7050,12 +7059,15 @@ function testCheckSummary(c) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || '');
|
if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || '');
|
||||||
if (c.kind === 'por') {
|
if (c.kind === 'por') return 'por ' + testPorSpecSummary(c);
|
||||||
|
if (c.kind === 'sms') {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (c.status) parts.push('status ' + c.status);
|
if (c.da) parts.push('DA ' + c.da);
|
||||||
if (c.sw) parts.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value);
|
if (c.pid) parts.push('PID ' + (c.pid.mode === 'mask' ? '~' : '') + c.pid.value);
|
||||||
if (c.data) parts.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value);
|
if (c.dcs) parts.push('DCS ' + (c.dcs.mode === 'mask' ? '~' : '') + c.dcs.value);
|
||||||
return 'por ' + parts.join(' ');
|
if (c.udl !== undefined) parts.push('UDL ' + c.udl);
|
||||||
|
if (c.ud) parts.push('UD ' + (c.ud.mode === 'mask' ? '~' : '') + c.ud.value);
|
||||||
|
return 'sms ' + parts.join(' ');
|
||||||
}
|
}
|
||||||
if (c.kind === 'files') return 'files ' + (c.files || []).join(' ');
|
if (c.kind === 'files') return 'files ' + (c.files || []).join(' ');
|
||||||
return '';
|
return '';
|
||||||
@@ -7090,7 +7102,7 @@ function testStepSummary(step) {
|
|||||||
s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?')));
|
s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?')));
|
||||||
}
|
}
|
||||||
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
||||||
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '');
|
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '') + (p.format && p.format !== 'compact' ? ' fmt=' + p.format : '');
|
||||||
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
||||||
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
|
else if (step.kind === 'file-read') s = 'READ ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '');
|
||||||
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
|
else if (step.kind === 'status') s = 'STATUS' + ((p.attempts || 1) > 1 ? ' x' + p.attempts + ' (poll)' : '');
|
||||||
@@ -7099,7 +7111,9 @@ function testStepSummary(step) {
|
|||||||
const ck = [];
|
const ck = [];
|
||||||
if (c.sw && c.sw.value && !(c.sw.mode !== 'mask' && c.sw.value === '9000')) ck.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value);
|
if (c.sw && c.sw.value && !(c.sw.mode !== 'mask' && c.sw.value === '9000')) ck.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value);
|
||||||
if (c.data && c.data.value) ck.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value);
|
if (c.data && c.data.value) ck.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value);
|
||||||
if (c.por && c.por !== 'any') ck.push('PoR ' + c.por);
|
if (c.por && c.por !== 'any') {
|
||||||
|
ck.push('PoR ' + (typeof c.por === 'string' ? c.por : testPorSpecSummary(c.por)));
|
||||||
|
}
|
||||||
if (step.on_fail === 'warning') ck.push('⚠');
|
if (step.on_fail === 'warning') ck.push('⚠');
|
||||||
return s + (ck.length ? ' · ' + ck.join(', ') : '');
|
return s + (ck.length ? ' · ' + ck.join(', ') : '');
|
||||||
}
|
}
|
||||||
@@ -7390,7 +7404,13 @@ function testStepRender() {
|
|||||||
html += testFormRow('Source', testFormSelect('test-f-src', src, [
|
html += testFormRow('Source', testFormSelect('test-f-src', src, [
|
||||||
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
|
{v: 'apdu', l: t('C-APDU (built with the preset)')}, {v: 'sp', l: t('Pre-built secured packet')}], 'testStepKindChanged()'));
|
||||||
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
|
if (src === 'sp') html += testFormRow('Secured packet (hex)', testFormInput('test-f-sp', p.sp, '01 00 ...'));
|
||||||
else html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
|
else {
|
||||||
|
html += testFormRow('C-APDU (hex)', testFormInput('test-f-apdu', p.apdu, '80E2900000'));
|
||||||
|
html += testFormRow('Command format', testFormSelect('test-f-fmt', p.format || 'compact', [
|
||||||
|
{v: 'compact', l: t('Compact')},
|
||||||
|
{v: 'expanded', l: t('Expanded (AA template)')},
|
||||||
|
{v: 'expanded-ae', l: t('Expanded (AE 80 template)')}]));
|
||||||
|
}
|
||||||
html += testFormRow('Keyset number (optional)',
|
html += testFormRow('Keyset number (optional)',
|
||||||
testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset')));
|
testFormInput('test-f-kvn', p.kvn === undefined ? '' : p.kvn, t('empty = the preset\'s first keyset')));
|
||||||
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
|
html += testFormRow('TAR override (hex, optional)', testFormInput('test-f-tar', p.tar, 'B00000'));
|
||||||
@@ -7410,8 +7430,15 @@ function testStepRender() {
|
|||||||
+ testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
+ testFormSelect('test-f-cdata-mode', cd.mode || 'exact', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}])
|
||||||
+ testFormInput('test-f-cdata', cd.value, 'AA??') + '</div>');
|
+ testFormInput('test-f-cdata', cd.value, 'AA??') + '</div>');
|
||||||
if (step.kind === 'scp80') {
|
if (step.kind === 'scp80') {
|
||||||
html += testFormRow('PoR check', testFormSelect('test-f-por', c.por || 'any', [
|
const porObj = c.por && typeof c.por === 'object' ? c.por : null;
|
||||||
{v: 'any', l: t('Any')}, {v: 'none', l: t('No PoR expected')}, {v: 'ok', l: t('PoR = por_ok')}]));
|
html += testFormRow('PoR check', testFormSelect('test-f-por', porObj ? 'custom' : (c.por || 'any'), [
|
||||||
|
{v: 'any', l: t('Any')}, {v: 'none', l: t('No PoR expected')}, {v: 'ok', l: t('PoR = por_ok')},
|
||||||
|
{v: 'custom', l: t('Custom (status / SW / data)')}], 'testStepKindChanged()'));
|
||||||
|
if (porObj) {
|
||||||
|
html += testFormRow('PoR status', testFormInput('test-f-porstatus', porObj.status, 'por_ok'));
|
||||||
|
html += testFormRow('PoR SW (hex)', testFormInput('test-f-por-sw', porObj.sw && porObj.sw.value, '6A88'));
|
||||||
|
html += testFormRow('PoR data (hex)', testFormInput('test-f-por-data', porObj.data && porObj.data.value, 'AA??'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
html += testFormRow('On mismatch', testFormSelect('test-f-fail', step.on_fail || 'error', [
|
html += testFormRow('On mismatch', testFormSelect('test-f-fail', step.on_fail || 'error', [
|
||||||
{v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}]));
|
{v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}]));
|
||||||
@@ -7478,7 +7505,8 @@ function testRenderChecks() {
|
|||||||
html += '<div class="flex items-start gap-2 mb-1">'
|
html += '<div class="flex items-start gap-2 mb-1">'
|
||||||
+ testFormSelect('test-check-kind-' + i, c.kind || 'text', [
|
+ testFormSelect('test-check-kind-' + i, c.kind || 'text', [
|
||||||
{v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')},
|
{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()');
|
{v: 'alpha', l: t('Alpha')}, {v: 'por', l: t('PoR')}, {v: 'sms', l: t('SMS TPDU')},
|
||||||
|
{v: 'files', l: t('Files')}], 'testCheckKindChanged()');
|
||||||
if (c.kind === 'item') {
|
if (c.kind === 'item') {
|
||||||
html += testFormInput('test-check-id-' + i, c.id, t('id'));
|
html += testFormInput('test-check-id-' + i, c.id, t('id'));
|
||||||
html += testFormInput('test-check-text-' + i, c.text, t('text'));
|
html += testFormInput('test-check-text-' + i, c.text, t('text'));
|
||||||
@@ -7491,6 +7519,12 @@ function testRenderChecks() {
|
|||||||
html += testFormInput('test-check-porstatus-' + i, c.status, 'por_ok');
|
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-sw-' + i, c.sw && c.sw.value, '6A88');
|
||||||
html += testFormInput('test-check-por-data-' + i, c.data && c.data.value, 'AA??');
|
html += testFormInput('test-check-por-data-' + i, c.data && c.data.value, 'AA??');
|
||||||
|
} else if (c.kind === 'sms') {
|
||||||
|
html += testFormInput('test-check-smsda-' + i, c.da, '12345');
|
||||||
|
html += testFormInput('test-check-smspid-' + i, c.pid && c.pid.value, '7F');
|
||||||
|
html += testFormInput('test-check-smsdcs-' + i, c.dcs && c.dcs.value, '00');
|
||||||
|
html += testFormInput('test-check-smsudl-' + i, c.udl === undefined ? '' : c.udl, 'UDL');
|
||||||
|
html += testFormInput('test-check-smsud-' + i, c.ud && c.ud.value, 'AA??');
|
||||||
} else if (c.kind === 'files') {
|
} else if (c.kind === 'files') {
|
||||||
html += testFormInput('test-check-files-' + i, (c.files || []).join(', '), '3F007F106F3A');
|
html += testFormInput('test-check-files-' + i, (c.files || []).join(', '), '3F007F106F3A');
|
||||||
} else {
|
} else {
|
||||||
@@ -7544,6 +7578,17 @@ function testChecksCollect() {
|
|||||||
if (sw) row.sw = {mode: sw.includes('?') ? 'mask' : 'exact', value: sw};
|
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();
|
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};
|
if (data) row.data = {mode: data.includes('?') ? 'mask' : 'exact', value: data};
|
||||||
|
} else if (kind === 'sms') {
|
||||||
|
const da = val('test-check-smsda-' + i);
|
||||||
|
if (da) row.da = da;
|
||||||
|
const pid = val('test-check-smspid-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
|
||||||
|
if (pid) row.pid = {mode: pid.includes('?') ? 'mask' : 'exact', value: pid};
|
||||||
|
const dcs = val('test-check-smsdcs-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
|
||||||
|
if (dcs) row.dcs = {mode: dcs.includes('?') ? 'mask' : 'exact', value: dcs};
|
||||||
|
const udl = val('test-check-smsudl-' + i);
|
||||||
|
if (udl !== '' && !isNaN(parseInt(udl, 10))) row.udl = parseInt(udl, 10);
|
||||||
|
const ud = val('test-check-smsud-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase();
|
||||||
|
if (ud) row.ud = {mode: ud.includes('?') ? 'mask' : 'exact', value: ud};
|
||||||
} else if (kind === 'files') {
|
} else if (kind === 'files') {
|
||||||
row.files = val('test-check-files-' + i).split(/[,\s]+/)
|
row.files = val('test-check-files-' + i).split(/[,\s]+/)
|
||||||
.map(s => s.replace(/[^0-9a-fA-F]/g, '').toUpperCase()).filter(Boolean);
|
.map(s => s.replace(/[^0-9a-fA-F]/g, '').toUpperCase()).filter(Boolean);
|
||||||
@@ -7621,6 +7666,8 @@ function testStepCollect() {
|
|||||||
['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; });
|
['tar', 'spi1', 'spi2'].forEach(k => { const v = hexNoMask('test-f-' + k); if (v) p[k] = v; });
|
||||||
const kvn = (document.getElementById('test-f-kvn') || {}).value;
|
const kvn = (document.getElementById('test-f-kvn') || {}).value;
|
||||||
if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10);
|
if (kvn !== undefined && String(kvn).trim() !== '') p.kvn = parseInt(String(kvn).trim(), 10);
|
||||||
|
const fmt = val('test-f-fmt');
|
||||||
|
if (fmt && fmt !== 'compact') p.format = fmt;
|
||||||
}
|
}
|
||||||
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
|
else if (kind === 'status') { p.attempts = num('test-f-attempts', 1); p.interval_ms = num('test-f-interval', 200); }
|
||||||
step.params = p;
|
step.params = p;
|
||||||
@@ -7630,7 +7677,19 @@ function testStepCollect() {
|
|||||||
const dataVal = hex('test-f-cdata');
|
const dataVal = hex('test-f-cdata');
|
||||||
if (dataVal) check.data = {mode: val('test-f-cdata-mode') || 'exact', value: dataVal};
|
if (dataVal) check.data = {mode: val('test-f-cdata-mode') || 'exact', value: dataVal};
|
||||||
const por = val('test-f-por');
|
const por = val('test-f-por');
|
||||||
if (kind === 'scp80' && por && por !== 'any') check.por = por;
|
if (kind === 'scp80' && por === 'custom') {
|
||||||
|
// the decoded-PoR assertion: status / SW / data, at least one
|
||||||
|
const spec = {};
|
||||||
|
const st = val('test-f-porstatus');
|
||||||
|
if (st) spec.status = st;
|
||||||
|
const psw = hex('test-f-por-sw');
|
||||||
|
if (psw) spec.sw = {mode: psw.includes('?') ? 'mask' : 'exact', value: psw};
|
||||||
|
const pdata = hex('test-f-por-data');
|
||||||
|
if (pdata) spec.data = {mode: pdata.includes('?') ? 'mask' : 'exact', value: pdata};
|
||||||
|
check.por = spec;
|
||||||
|
} else if (kind === 'scp80' && por && por !== 'any') {
|
||||||
|
check.por = por;
|
||||||
|
}
|
||||||
step.check = check;
|
step.check = check;
|
||||||
step.on_fail = val('test-f-fail') || 'error';
|
step.on_fail = val('test-f-fail') || 'error';
|
||||||
const label = val('test-f-label');
|
const label = val('test-f-label');
|
||||||
@@ -7645,6 +7704,7 @@ function testStepCollect() {
|
|||||||
if (c.kind === 'text') return !!(c.value || '').trim();
|
if (c.kind === 'text') return !!(c.value || '').trim();
|
||||||
if (c.kind === 'item') return c.id || (c.text || '').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 === 'por') return !!(c.status || c.sw || c.data);
|
||||||
|
if (c.kind === 'sms') return !!(c.da || c.pid || c.dcs || c.udl !== undefined || c.ud);
|
||||||
if (c.kind === 'files') return !!(c.files && c.files.length);
|
if (c.kind === 'files') return !!(c.files && c.files.length);
|
||||||
return !!(c.value || '').trim();
|
return !!(c.value || '').trim();
|
||||||
});
|
});
|
||||||
@@ -7669,6 +7729,10 @@ function testStepFormError() {
|
|||||||
if (step.kind === 'scp80') {
|
if (step.kind === 'scp80') {
|
||||||
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
const src = p.source || (p.sp ? 'sp' : 'apdu');
|
||||||
if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet');
|
if (src === 'sp' ? !p.sp : !p.apdu) return t('SCP80 needs an APDU or a secured packet');
|
||||||
|
const pc = (step.check || {}).por;
|
||||||
|
if (pc && typeof pc === 'object' && !pc.status && !pc.sw && !pc.data) {
|
||||||
|
return t('PoR check: status, SW or data is required');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (step.kind === 'menu-select') {
|
if (step.kind === 'menu-select') {
|
||||||
const problem = testMenuSelectProblem(p);
|
const problem = testMenuSelectProblem(p);
|
||||||
@@ -21116,6 +21180,17 @@ const LANG_RU = {
|
|||||||
'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)',
|
'default: 9000 (91?? when polling)': 'по умолчанию: 9000 (91?? при опросе)',
|
||||||
'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)',
|
'Expected data (hex, optional)': 'Ожидаемые данные (hex, опционально)',
|
||||||
'PoR check': 'Проверка PoR',
|
'PoR check': 'Проверка PoR',
|
||||||
|
'Custom (status / SW / data)': 'Своя проверка (status / SW / data)',
|
||||||
|
'PoR status': 'Статус PoR',
|
||||||
|
'PoR SW (hex)': 'SW PoR (hex)',
|
||||||
|
'PoR data (hex)': 'Данные PoR (hex)',
|
||||||
|
'PoR check: status, SW or data is required': 'Проверка PoR: нужен status, SW или data',
|
||||||
|
'Command format': 'Формат команды',
|
||||||
|
'Compact': 'Компактный',
|
||||||
|
'Expanded (AA template)': 'Расширенный (шаблон AA)',
|
||||||
|
'Expanded (AE 80 template)': 'Расширенный (шаблон AE 80)',
|
||||||
|
'Alpha': 'Alpha',
|
||||||
|
'SMS TPDU': 'TPDU SMS',
|
||||||
'PoR ok': 'PoR: успешно',
|
'PoR ok': 'PoR: успешно',
|
||||||
'no PoR': 'PoR: нет',
|
'no PoR': 'PoR: нет',
|
||||||
'PoR error': 'Ошибка PoR',
|
'PoR error': 'Ошибка PoR',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v386';
|
const CACHE = 'simple-v387';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^c
|
|||||||
eval(extractFunc(html, 'testTemplate'));
|
eval(extractFunc(html, 'testTemplate'));
|
||||||
eval(extractFunc(html, 'testMenuSelectProblem'));
|
eval(extractFunc(html, 'testMenuSelectProblem'));
|
||||||
eval(extractFunc(html, 'testScriptProblem'));
|
eval(extractFunc(html, 'testScriptProblem'));
|
||||||
|
eval(extractFunc(html, 'testPorSpecSummary'));
|
||||||
eval(extractFunc(html, 'testCheckSummary'));
|
eval(extractFunc(html, 'testCheckSummary'));
|
||||||
eval(extractFunc(html, 'testStepSummary'));
|
eval(extractFunc(html, 'testStepSummary'));
|
||||||
eval(extractFunc(html, 'testCommandOptions'));
|
eval(extractFunc(html, 'testCommandOptions'));
|
||||||
@@ -194,6 +195,97 @@ test('the SCP80 source switch sticks and preserves the other value', () => {
|
|||||||
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the SCP80 step collects the format and the custom PoR object', () => {
|
||||||
|
_testEditStep = { type: 'action', kind: 'scp80', params: { apdu: '80E2900000' } };
|
||||||
|
_testEditChecks = [];
|
||||||
|
_testEditStepIndex = 0;
|
||||||
|
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||||
|
'test-f-fmt': 'expanded', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
|
||||||
|
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'custom',
|
||||||
|
'test-f-porstatus': 'por_ok', 'test-f-por-sw': '6a8?', 'test-f-por-data': 'aa??',
|
||||||
|
'test-f-fail': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.strictEqual(_testEditStep.params.format, 'expanded');
|
||||||
|
assert.deepStrictEqual(_testEditStep.check.por,
|
||||||
|
{ status: 'por_ok', sw: { mode: 'mask', value: '6A8?' },
|
||||||
|
data: { mode: 'mask', value: 'AA??' } });
|
||||||
|
// a custom PoR check with no fields refuses to save
|
||||||
|
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||||
|
'test-f-fmt': 'compact', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
|
||||||
|
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'custom',
|
||||||
|
'test-f-fail': 'error' });
|
||||||
|
assert.match(testStepFormError(), /PoR check/);
|
||||||
|
assert.ok(!('format' in _testEditStep.params)); // compact is the default
|
||||||
|
// the plain string forms stay and their object is dropped on switch
|
||||||
|
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||||
|
'test-f-fmt': '', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
|
||||||
|
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'none',
|
||||||
|
'test-f-fail': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.strictEqual(_testEditStep.check.por, 'none');
|
||||||
|
// the summary renders the effective format and the object's fields
|
||||||
|
const s = testStepSummary({ type: 'action', kind: 'scp80',
|
||||||
|
params: { apdu: '80E2', format: 'expanded-ae' },
|
||||||
|
check: { por: { status: 'por_ok', sw: { mode: 'mask', value: '6A8?' } } } });
|
||||||
|
assert.ok(s.includes('fmt=expanded-ae'), s);
|
||||||
|
assert.ok(s.includes('PoR status por_ok SW ~6A8?'), s);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the SCP80 step form renders the format select and the custom PoR fields', () => {
|
||||||
|
const els = {
|
||||||
|
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
'test-step-title': {}, 'test-step-body': {},
|
||||||
|
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
_testEditStep = { type: 'action', kind: 'scp80',
|
||||||
|
params: { apdu: '80E2900000', format: 'expanded' },
|
||||||
|
check: { por: { status: 'por_ok' } } };
|
||||||
|
_testEditChecks = [];
|
||||||
|
_testEditStepIndex = 0;
|
||||||
|
testStepRender();
|
||||||
|
const body = els['test-step-body'].innerHTML;
|
||||||
|
assert.match(body, /id="test-f-fmt"/);
|
||||||
|
assert.match(body, /<option value="expanded" selected>/);
|
||||||
|
assert.match(body, /<option value="custom" selected>/);
|
||||||
|
assert.match(body, /id="test-f-porstatus" value="por_ok"/);
|
||||||
|
assert.match(body, /id="test-f-por-sw" value=""/);
|
||||||
|
// the pre-built packet source has no format select (the wrapper applies
|
||||||
|
// to a C-APDU only)
|
||||||
|
_testEditStep = { type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AABB' } };
|
||||||
|
testStepRender();
|
||||||
|
assert.ok(!els['test-step-body'].innerHTML.includes('id="test-f-fmt"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the alpha and sms checks collect and summarise', () => {
|
||||||
|
assert.strictEqual(testCheckSummary({ kind: 'alpha', mode: 'exact', value: 'Alfa' }),
|
||||||
|
'alpha ="Alfa"');
|
||||||
|
assert.strictEqual(testCheckSummary({ kind: 'sms', da: '12345',
|
||||||
|
pid: { mode: 'exact', value: '7F' }, dcs: { mode: 'exact', value: '00' },
|
||||||
|
udl: 5, ud: { mode: 'mask', value: 'AA??' } }),
|
||||||
|
'sms DA 12345 PID 7F DCS 00 UDL 5 UD ~AA??');
|
||||||
|
// the sms fields collect with hex masks and a decimal UDL (0 included)
|
||||||
|
_testEditChecks = [{ kind: 'sms' }];
|
||||||
|
fakeForm({ 'test-check-kind-0': 'sms', 'test-check-smsda-0': '12345',
|
||||||
|
'test-check-smspid-0': '7f', 'test-check-smsdcs-0': '00',
|
||||||
|
'test-check-smsudl-0': '0', 'test-check-smsud-0': 'aa??',
|
||||||
|
'test-check-fail-0': 'error' });
|
||||||
|
testChecksCollect();
|
||||||
|
assert.deepStrictEqual(_testEditChecks, [{ kind: 'sms', on_fail: 'error', da: '12345',
|
||||||
|
pid: { mode: 'exact', value: '7F' }, dcs: { mode: 'exact', value: '00' }, udl: 0,
|
||||||
|
ud: { mode: 'mask', value: 'AA??' } }]);
|
||||||
|
// an empty sms check is dropped on save; an alpha with a value survives
|
||||||
|
_testEditStep = { type: 'expect', command: 'SEND SHORT MESSAGE', checks: [],
|
||||||
|
respond: { result: 'ok' } };
|
||||||
|
_testEditChecks = [{ kind: 'sms' }, { kind: 'alpha', mode: 'contains', value: 'Alfa' }];
|
||||||
|
fakeForm({ 'test-check-kind-0': 'sms', 'test-check-fail-0': 'error',
|
||||||
|
'test-check-kind-1': 'alpha', 'test-check-mode-1': 'contains',
|
||||||
|
'test-check-value-1': 'Alfa', 'test-check-fail-1': 'error' });
|
||||||
|
testStepCollect();
|
||||||
|
assert.deepStrictEqual(_testEditStep.checks,
|
||||||
|
[{ kind: 'alpha', on_fail: 'error', mode: 'contains', value: 'Alfa' }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('the menu-select editor collects the item id or the text', () => {
|
test('the menu-select editor collects the item id or the text', () => {
|
||||||
// by text (the id varies with the applet's install parameters)
|
// by text (the id varies with the applet's install parameters)
|
||||||
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
|
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.18.4"
|
version = "3.19.0"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
+129
-35
@@ -34,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.18.4'
|
VERSION = '3.19.0'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -2783,6 +2783,34 @@ def _decode_sms_ud(pid, dcs, ud, udhi, udl):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sms_tpdu_fields(raw):
|
||||||
|
"""Parse the TPDU of a SEND SHORT MESSAGE command into the fields a test
|
||||||
|
script can assert (the same SMS_SUBMIT parse the log uses): the message
|
||||||
|
type, DA digits, PID/DCS, UDL and the raw user data. None when the
|
||||||
|
command carries no TPDU."""
|
||||||
|
tpdu_hex = _find_sms_tpdu(raw)
|
||||||
|
if not tpdu_hex:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tpdu = bytes.fromhex(tpdu_hex)
|
||||||
|
mti = tpdu[0] & 0x03
|
||||||
|
out = {'mti': mti, 'tpdu': tpdu_hex.upper()}
|
||||||
|
if mti != 1:
|
||||||
|
return out
|
||||||
|
from pySim.sms import SMS_SUBMIT
|
||||||
|
s = SMS_SUBMIT.from_bytes(tpdu)
|
||||||
|
out.update({
|
||||||
|
'da': str(getattr(s.tp_da, 'digits', '') or '').rstrip('fF'),
|
||||||
|
'pid': '%02X' % s.tp_pid,
|
||||||
|
'dcs': '%02X' % s.tp_dcs,
|
||||||
|
'udl': int(s.tp_udl),
|
||||||
|
'ud': bytes(s.tp_ud).hex().upper(),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _decode_send_sm(raw):
|
def _decode_send_sm(raw):
|
||||||
"""Decode a SEND SHORT MESSAGE command (TS 102 223 6.4.10): alpha
|
"""Decode a SEND SHORT MESSAGE command (TS 102 223 6.4.10): alpha
|
||||||
identifier, address and the 3GPP-SMS TPDU with its user data."""
|
identifier, address and the 3GPP-SMS TPDU with its user data."""
|
||||||
@@ -5767,12 +5795,41 @@ def _test_finish_entry(entry, result):
|
|||||||
fields = {'status': result.get('status', 'ok'),
|
fields = {'status': result.get('status', 'ok'),
|
||||||
'checks': result.get('checks', []),
|
'checks': result.get('checks', []),
|
||||||
'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)}
|
'ms': int((time.time() - (entry.get('started') or time.time())) * 1000)}
|
||||||
for key in ('sent', 'sw', 'data', 'note', 'por', 'counter', 'command'):
|
for key in ('sent', 'sw', 'data', 'note', 'por', 'sms', 'counter', 'command'):
|
||||||
if result.get(key) is not None:
|
if result.get(key) is not None:
|
||||||
fields[key] = result[key]
|
fields[key] = result[key]
|
||||||
_test_entry_update(entry, fields)
|
_test_entry_update(entry, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _por_check_results(spec, por, level, prefix='PoR'):
|
||||||
|
"""The decoded-PoR assertion rows (status/sw/data), shared by the scp80
|
||||||
|
action's `check.por` object and the expectation's `por` content check."""
|
||||||
|
checks = []
|
||||||
|
if por is None:
|
||||||
|
checks.append(_test_check_result(
|
||||||
|
prefix, False, 'a decoded PoR',
|
||||||
|
'no PoR in this command (or no scp80 step before it)', level))
|
||||||
|
return checks
|
||||||
|
if 'status' in spec:
|
||||||
|
actual = str(por.get('response_status') or '')
|
||||||
|
want = spec['status']
|
||||||
|
ok = (actual.lower() == want.lower()
|
||||||
|
or (want.lower() == 'ok' and actual == 'por_ok'))
|
||||||
|
checks.append(_test_check_result(prefix + ' status', ok, want,
|
||||||
|
actual or '(none)', level))
|
||||||
|
if 'sw' in spec:
|
||||||
|
actual = str((por.get('decoded') or {}).get('last_status_word') or '')
|
||||||
|
checks.append(_test_check_result(
|
||||||
|
prefix + ' SW', testscript.match_value(spec['sw'], actual),
|
||||||
|
spec['sw']['value'], actual or '(none)', level))
|
||||||
|
if 'data' in spec:
|
||||||
|
actual = str((por.get('decoded') or {}).get('last_response_data') or '')
|
||||||
|
checks.append(_test_check_result(
|
||||||
|
prefix + ' data', testscript.match_value(spec['data'], actual),
|
||||||
|
spec['data']['value'], actual or '(none)', level))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
def _test_action_checks(step, sw, data, por, kind):
|
def _test_action_checks(step, sw, data, por, kind):
|
||||||
check = step['check']
|
check = step['check']
|
||||||
level = step['on_fail']
|
level = step['on_fail']
|
||||||
@@ -5786,15 +5843,19 @@ def _test_action_checks(step, sw, data, por, kind):
|
|||||||
else:
|
else:
|
||||||
checks.append(_test_check_result('Data', True, check['data']['value'],
|
checks.append(_test_check_result('Data', True, check['data']['value'],
|
||||||
'(not checked - SW mismatch)', 'ok'))
|
'(not checked - SW mismatch)', 'ok'))
|
||||||
if kind == 'scp80' and check.get('por') != 'any':
|
if kind == 'scp80':
|
||||||
want = check['por']
|
want = check.get('por')
|
||||||
if want == 'none':
|
if isinstance(want, dict):
|
||||||
ok = por is None
|
# the decoded-PoR assertion (inline or submit transport)
|
||||||
actual = 'none' if por is None else 'present'
|
checks.extend(_por_check_results(want, por, level))
|
||||||
else:
|
elif want != 'any':
|
||||||
ok = bool(por) and por.get('response_status') == 'por_ok'
|
if want == 'none':
|
||||||
actual = (por or {}).get('response_status') or 'none'
|
ok = por is None
|
||||||
checks.append(_test_check_result('PoR', ok, want, actual, level))
|
actual = 'none' if por is None else 'present'
|
||||||
|
else:
|
||||||
|
ok = bool(por) and por.get('response_status') == 'por_ok'
|
||||||
|
actual = (por or {}).get('response_status') or 'none'
|
||||||
|
checks.append(_test_check_result('PoR', ok, want, actual, level))
|
||||||
return checks
|
return checks
|
||||||
|
|
||||||
|
|
||||||
@@ -5899,15 +5960,25 @@ def _test_run_scp80(server, step, ctx):
|
|||||||
ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
|
ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
|
||||||
'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key,
|
'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key,
|
||||||
'tar': tar}
|
'tar': tar}
|
||||||
|
fmt = p.get('format') or 'compact'
|
||||||
|
apdu_hex = p.get('apdu') or ''
|
||||||
if p.get('sp'):
|
if p.get('sp'):
|
||||||
sp_hex = p['sp']
|
sp_hex = p['sp']
|
||||||
source = 'sp'
|
source = 'sp'
|
||||||
_test_log('SCP80 SP=%s' % sp_hex)
|
_test_log('SCP80 SP=%s' % sp_hex)
|
||||||
else:
|
else:
|
||||||
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, counter,
|
|
||||||
p['apdu'], kic_key, kid_key)
|
|
||||||
source = 'apdu'
|
source = 'apdu'
|
||||||
_test_log('SCP80 C-APDU=%s' % p['apdu'])
|
if fmt != 'compact':
|
||||||
|
# TS 102 226 5.2.1: wrap the C-APDU in the Command Scripting
|
||||||
|
# template (the expanded remote-management format the RFM/GP
|
||||||
|
# applications expect for multi-command payloads)
|
||||||
|
apdu_hex = _ram_format_apdu(apdu_hex, fmt)
|
||||||
|
_test_log('SCP80 C-APDU=%s WRAPPED=%s (%s)'
|
||||||
|
% (p['apdu'], apdu_hex, fmt))
|
||||||
|
else:
|
||||||
|
_test_log('SCP80 C-APDU=%s' % apdu_hex)
|
||||||
|
sp_hex, _ = _build_secured_packet(spi1, spi2, kic, kid, tar, counter,
|
||||||
|
apdu_hex, kic_key, kid_key)
|
||||||
_test_log('SCP80 SECURED=%s (%d B) TAR=%s SPI1=%s SPI2=%s CNTR=%s'
|
_test_log('SCP80 SECURED=%s (%d B) TAR=%s SPI1=%s SPI2=%s CNTR=%s'
|
||||||
% (sp_hex, len(sp_hex) // 2, tar or '-', spi1, spi2, counter))
|
% (sp_hex, len(sp_hex) // 2, tar or '-', spi1, spi2, counter))
|
||||||
result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc,
|
result = _send_secured_packet(scc, sp_hex, server.sms_oa, sm_sc=server.sms_sc,
|
||||||
@@ -5936,10 +6007,12 @@ def _test_run_scp80(server, step, ctx):
|
|||||||
_test_log('PoR[inline] undecodable raw=%s' % data)
|
_test_log('PoR[inline] undecodable raw=%s' % data)
|
||||||
else:
|
else:
|
||||||
_test_log('PoR[inline] none')
|
_test_log('PoR[inline] none')
|
||||||
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s %s' % (
|
sent = 'SCP80 %s TAR=%s SPI1=%s SPI2=%s cntr=%s%s %s%s' % (
|
||||||
source, tar or '-', spi1, spi2, counter,
|
source, tar or '-', spi1, spi2, counter,
|
||||||
(' kvn=%d' % kvn) if kvn else '',
|
(' kvn=%d' % kvn) if kvn else '',
|
||||||
('SP=%s' % sp_hex) if source == 'sp' else ('C-APDU=%s' % p['apdu']))
|
('SP=%s' % sp_hex) if source == 'sp'
|
||||||
|
else ('C-APDU=%s' % apdu_hex),
|
||||||
|
(' format=%s' % fmt) if (source == 'apdu' and fmt != 'compact') else '')
|
||||||
if sw and _counter_tracked(spi1):
|
if sw and _counter_tracked(spi1):
|
||||||
# The card answered and the packet asks for a counter check, so the
|
# The card answered and the packet asks for a counter check, so the
|
||||||
# SCP80 counter was consumed: advance the working value of this key
|
# SCP80 counter was consumed: advance the working value of this key
|
||||||
@@ -6158,6 +6231,12 @@ def _test_run_expect(server, step, pending, ctx=None):
|
|||||||
type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN')
|
type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN')
|
||||||
_test_log('CMD 0x%02X %s qual=%s' % (
|
_test_log('CMD 0x%02X %s qual=%s' % (
|
||||||
cmd_type, type_name, ('%02X' % cmd_qual) if cmd_qual is not None else '-'))
|
cmd_type, type_name, ('%02X' % cmd_qual) if cmd_qual is not None else '-'))
|
||||||
|
sms_fields = _sms_tpdu_fields(raw) if cmd_type == 0x13 else None
|
||||||
|
if sms_fields and sms_fields.get('mti') == 1:
|
||||||
|
_test_log('SMS DA=%s PID=%s DCS=%s UDL=%s UD=%s'
|
||||||
|
% (sms_fields.get('da'), sms_fields.get('pid'),
|
||||||
|
sms_fields.get('dcs'), sms_fields.get('udl'),
|
||||||
|
sms_fields.get('ud')))
|
||||||
# A SEND SHORT MESSAGE carries the PoR when the packet asked for it in
|
# 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
|
# submit mode (SPI2 bit 0x20): decode it with the triggering scp80 step's
|
||||||
# context so the checks can assert the PoR contents.
|
# context so the checks can assert the PoR contents.
|
||||||
@@ -6224,29 +6303,44 @@ def _test_run_expect(server, step, pending, ctx=None):
|
|||||||
testscript.match_value({'mode': c['mode'], 'value': c['value']}, actual),
|
testscript.match_value({'mode': c['mode'], 'value': c['value']}, actual),
|
||||||
c['value'], actual, c['on_fail']))
|
c['value'], actual, c['on_fail']))
|
||||||
elif c['kind'] == 'por':
|
elif c['kind'] == 'por':
|
||||||
if por is None:
|
checks.extend(_por_check_results(c, por, c['on_fail']))
|
||||||
|
elif c['kind'] == 'alpha':
|
||||||
|
alpha = _cmd_tlv(httpota.proactive_tlvs(raw), 0x05)
|
||||||
|
text = None
|
||||||
|
if alpha:
|
||||||
|
try:
|
||||||
|
text = _STK_DECODE._decode(alpha, {}, 'stk')
|
||||||
|
except Exception:
|
||||||
|
text = None
|
||||||
|
checks.append(_test_check_result(
|
||||||
|
'Alpha', testscript.match_text(c, text),
|
||||||
|
c['value'], text if text is not None else '(none)', c['on_fail']))
|
||||||
|
elif c['kind'] == 'sms':
|
||||||
|
if sms_fields is None or sms_fields.get('mti') != 1:
|
||||||
checks.append(_test_check_result(
|
checks.append(_test_check_result(
|
||||||
'PoR', False, 'a decoded PoR',
|
'SMS', False, 'a SUBMIT TPDU', 'none (not an SMS-SUBMIT)',
|
||||||
'no PoR in this command (or no scp80 step before it)',
|
|
||||||
c['on_fail']))
|
c['on_fail']))
|
||||||
else:
|
else:
|
||||||
if 'status' in c:
|
if 'da' 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(
|
checks.append(_test_check_result(
|
||||||
'PoR SW', testscript.match_value(c['sw'], actual),
|
'SMS DA', sms_fields.get('da') == c['da'], c['da'],
|
||||||
c['sw']['value'], actual or '(none)', c['on_fail']))
|
sms_fields.get('da') or '(none)', c['on_fail']))
|
||||||
if 'data' in c:
|
for label, key in (('PID', 'pid'), ('DCS', 'dcs')):
|
||||||
actual = str((por.get('decoded') or {}).get('last_response_data') or '')
|
if key in c:
|
||||||
|
actual = sms_fields.get(key) or ''
|
||||||
|
checks.append(_test_check_result(
|
||||||
|
'SMS ' + label, testscript.match_value(c[key], actual),
|
||||||
|
c[key]['value'], actual or '(none)', c['on_fail']))
|
||||||
|
if 'udl' in c:
|
||||||
|
actual = sms_fields.get('udl')
|
||||||
checks.append(_test_check_result(
|
checks.append(_test_check_result(
|
||||||
'PoR data', testscript.match_value(c['data'], actual),
|
'SMS UDL', actual == c['udl'], str(c['udl']),
|
||||||
c['data']['value'], actual or '(none)', c['on_fail']))
|
'(none)' if actual is None else str(actual), c['on_fail']))
|
||||||
|
if 'ud' in c:
|
||||||
|
actual = sms_fields.get('ud') or ''
|
||||||
|
checks.append(_test_check_result(
|
||||||
|
'SMS UD', testscript.match_value(c['ud'], actual),
|
||||||
|
c['ud']['value'], actual or '(none)', c['on_fail']))
|
||||||
elif c['kind'] == 'files':
|
elif c['kind'] == 'files':
|
||||||
paths = _parse_file_list(raw)
|
paths = _parse_file_list(raw)
|
||||||
want = c['files']
|
want = c['files']
|
||||||
@@ -6261,7 +6355,7 @@ def _test_run_expect(server, step, pending, ctx=None):
|
|||||||
status = testscript.combine_levels([c['level'] if not c['ok'] else 'ok' for c in checks])
|
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(),
|
result = {'status': status, 'sw': tr_sw, 'data': raw.hex().upper(),
|
||||||
'sent': 'TR ' + tr.hex().upper(), 'checks': checks,
|
'sent': 'TR ' + tr.hex().upper(), 'checks': checks,
|
||||||
'por': por,
|
'por': por, 'sms': sms_fields,
|
||||||
'command': {'type_hex': '%02X' % cmd_type, 'type_name': type_name,
|
'command': {'type_hex': '%02X' % cmd_type, 'type_name': type_name,
|
||||||
'qualifier': '%02X' % cmd_qual if cmd_qual is not None else None,
|
'qualifier': '%02X' % cmd_qual if cmd_qual is not None else None,
|
||||||
'raw': raw.hex().upper()}}
|
'raw': raw.hex().upper()}}
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ class ScriptError(ValueError):
|
|||||||
|
|
||||||
# ─── normalisation helpers ──────────────────────────────────────────────
|
# ─── normalisation helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _por_fields(c, what='por check'):
|
||||||
|
"""The decoded-PoR assertion fields (status/sw/data); at least one."""
|
||||||
|
out = {}
|
||||||
|
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'), what + ' sw')
|
||||||
|
if c.get('data') not in (None, ''):
|
||||||
|
out['data'] = _check_spec(c.get('data'), what + ' data')
|
||||||
|
if not (out.get('status') or out.get('sw') or out.get('data')):
|
||||||
|
raise ScriptError(what + ': status, sw or data is required')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _fail_level(value, default='error'):
|
def _fail_level(value, default='error'):
|
||||||
if value in (None, ''):
|
if value in (None, ''):
|
||||||
return default
|
return default
|
||||||
@@ -312,6 +327,15 @@ def _normalise_params(kind, p):
|
|||||||
# the counter stay preset-owned, the step only picks which keyset.
|
# the counter stay preset-owned, the step only picks which keyset.
|
||||||
if p.get('kvn') not in (None, ''):
|
if p.get('kvn') not in (None, ''):
|
||||||
out['kvn'] = _int(p.get('kvn'), 'scp80 keyset number', 1, 15)
|
out['kvn'] = _int(p.get('kvn'), 'scp80 keyset number', 1, 15)
|
||||||
|
# The command format: the C-APDU verbatim (compact, the default) or
|
||||||
|
# wrapped in the expanded Command Scripting template (TS 102 226
|
||||||
|
# 5.2.1) - `expanded` = AA/<len>/22/..., `expanded-ae` = AE 80/22/00 00.
|
||||||
|
fmt = str(p.get('format') or '').strip().lower()
|
||||||
|
if fmt:
|
||||||
|
if fmt not in ('compact', 'expanded', 'expanded-ae'):
|
||||||
|
raise ScriptError('scp80: format must be compact, expanded or '
|
||||||
|
'expanded-ae')
|
||||||
|
out['format'] = fmt
|
||||||
return out
|
return out
|
||||||
if kind == 'status':
|
if kind == 'status':
|
||||||
attempts = p.get('attempts')
|
attempts = p.get('attempts')
|
||||||
@@ -337,14 +361,20 @@ def _normalise_check(check, kind, params):
|
|||||||
sw = {'mode': 'exact', 'value': '9000'}
|
sw = {'mode': 'exact', 'value': '9000'}
|
||||||
data = _check_spec(check.get('data'), 'check.data')
|
data = _check_spec(check.get('data'), 'check.data')
|
||||||
por = check.get('por')
|
por = check.get('por')
|
||||||
if por is None:
|
if isinstance(por, dict):
|
||||||
|
# The decoded-PoR assertion (available on scp80 actions whether the
|
||||||
|
# PoR arrived inline or via SEND SHORT MESSAGE).
|
||||||
|
if kind != 'scp80':
|
||||||
|
raise ScriptError('check.por is only valid for scp80 actions')
|
||||||
|
por = _por_fields(por, 'check.por')
|
||||||
|
elif por is None:
|
||||||
por = 'any'
|
por = 'any'
|
||||||
else:
|
else:
|
||||||
por = str(por).lower()
|
por = str(por).lower()
|
||||||
if kind != 'scp80':
|
if kind != 'scp80':
|
||||||
raise ScriptError('check.por is only valid for scp80 actions')
|
raise ScriptError('check.por is only valid for scp80 actions')
|
||||||
if por not in POR_CHECKS:
|
if por not in POR_CHECKS:
|
||||||
raise ScriptError('check.por must be none, ok or any')
|
raise ScriptError('check.por must be none, ok, any or an object')
|
||||||
return {'sw': sw, 'data': data, 'por': por}
|
return {'sw': sw, 'data': data, 'por': por}
|
||||||
|
|
||||||
|
|
||||||
@@ -416,15 +446,36 @@ def _normalise_content_check(c, default_level):
|
|||||||
'on_fail': level}
|
'on_fail': level}
|
||||||
if kind == 'por':
|
if kind == 'por':
|
||||||
out = {'kind': 'por', 'on_fail': level}
|
out = {'kind': 'por', 'on_fail': level}
|
||||||
status = c.get('status')
|
out.update(_por_fields(c, 'por check'))
|
||||||
if status is not None and str(status).strip():
|
return out
|
||||||
out['status'] = str(status).strip()
|
if kind == 'alpha':
|
||||||
if c.get('sw') not in (None, ''):
|
mode = str(c.get('mode') or 'contains').lower()
|
||||||
out['sw'] = _check_spec(c.get('sw'), 'por check sw')
|
if mode not in ('contains', 'exact'):
|
||||||
if c.get('data') not in (None, ''):
|
raise ScriptError('alpha check: mode must be contains or exact')
|
||||||
out['data'] = _check_spec(c.get('data'), 'por check data')
|
if c.get('value') is None:
|
||||||
if not (out.get('status') or out.get('sw') or out.get('data')):
|
raise ScriptError('alpha check: value is required')
|
||||||
raise ScriptError('por check: status, sw or data is required')
|
return {'kind': 'alpha', 'mode': mode, 'value': str(c['value']),
|
||||||
|
'case_sensitive': bool(c.get('case_sensitive', True)),
|
||||||
|
'on_fail': level}
|
||||||
|
if kind == 'sms':
|
||||||
|
# The SEND SHORT MESSAGE TPDU fields (an applet's own SMS, as opposed
|
||||||
|
# to the PoR of an OTA exchange).
|
||||||
|
out = {'kind': 'sms', 'on_fail': level}
|
||||||
|
da = c.get('da')
|
||||||
|
if da is not None and str(da).strip():
|
||||||
|
d = re.sub(r'\s', '', str(da))
|
||||||
|
if not re.fullmatch(r'[0-9*#+]{1,20}', d):
|
||||||
|
raise ScriptError('sms check: da must be up to 20 digits')
|
||||||
|
out['da'] = d
|
||||||
|
for key in ('pid', 'dcs'):
|
||||||
|
if c.get(key) not in (None, ''):
|
||||||
|
out[key] = _check_spec(c.get(key), 'sms check %s' % key)
|
||||||
|
if c.get('udl') not in (None, ''):
|
||||||
|
out['udl'] = _int(c.get('udl'), 'sms check udl', 0, 255)
|
||||||
|
if c.get('ud') not in (None, ''):
|
||||||
|
out['ud'] = _check_spec(c.get('ud'), 'sms check ud')
|
||||||
|
if not any(k in out for k in ('da', 'pid', 'dcs', 'udl', 'ud')):
|
||||||
|
raise ScriptError('sms check: da, pid, dcs, udl or ud is required')
|
||||||
return out
|
return out
|
||||||
if kind == 'files':
|
if kind == 'files':
|
||||||
val = c.get('files', c.get('value'))
|
val = c.get('files', c.get('value'))
|
||||||
|
|||||||
@@ -213,6 +213,72 @@ class TestValidation(unittest.TestCase):
|
|||||||
{'type': 'expect', 'command': 'ANY',
|
{'type': 'expect', 'command': 'ANY',
|
||||||
'checks': [{'kind': 'files', 'value': ['zz']}]}]}, _resolver)
|
'checks': [{'kind': 'files', 'value': ['zz']}]}]}, _resolver)
|
||||||
|
|
||||||
|
def test_scp80_format_param_and_por_object(self):
|
||||||
|
# v3.19.0: the scp80 step may wrap the C-APDU in the expanded Command
|
||||||
|
# Scripting template (TS 102 226 5.2.1) and assert the decoded PoR
|
||||||
|
# inline (`check.por` as an object).
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80',
|
||||||
|
'params': {'apdu': '80E2900000', 'format': 'expanded'},
|
||||||
|
'check': {'por': {'status': 'por_ok', 'sw': '6A8?',
|
||||||
|
'data': {'mode': 'mask', 'value': 'AA??'}}}},
|
||||||
|
]}, _resolver)
|
||||||
|
params = script['steps'][0]['params']
|
||||||
|
self.assertEqual(params['format'], 'expanded')
|
||||||
|
por = script['steps'][0]['check']['por']
|
||||||
|
self.assertEqual(por['status'], 'por_ok')
|
||||||
|
self.assertEqual(por['sw'], {'mode': 'mask', 'value': '6A8?'})
|
||||||
|
self.assertEqual(por['data'], {'mode': 'mask', 'value': 'AA??'})
|
||||||
|
# both codings are accepted; an unknown one is refused
|
||||||
|
for fmt in ('compact', 'expanded-ae'):
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80',
|
||||||
|
'params': {'apdu': '80E2', 'format': fmt}}]}, _resolver)
|
||||||
|
self.assertEqual(script['steps'][0]['params']['format'], fmt)
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80',
|
||||||
|
'params': {'apdu': '80E2', 'format': 'auto'}}]}, _resolver)
|
||||||
|
# an empty PoR object has nothing to assert; the object is scp80-only
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2'},
|
||||||
|
'check': {'por': {}}}]}, _resolver)
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1},
|
||||||
|
'check': {'por': {'status': 'por_ok'}}}]}, _resolver)
|
||||||
|
|
||||||
|
def test_alpha_and_sms_content_checks(self):
|
||||||
|
# `alpha` reads the Alpha identifier ('05', Annex A coded); `sms`
|
||||||
|
# asserts the SEND SHORT MESSAGE TPDU fields (submit only).
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'expect', 'command': 'DISPLAY TEXT',
|
||||||
|
'checks': [{'kind': 'alpha', 'mode': 'contains', 'value': 'Alfa',
|
||||||
|
'case_sensitive': False}]},
|
||||||
|
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
|
||||||
|
'checks': [{'kind': 'sms', 'da': '12 345', 'pid': '7F', 'dcs': '00',
|
||||||
|
'udl': 5, 'ud': '0271??AABB'}]},
|
||||||
|
]}, _resolver)
|
||||||
|
alpha = script['steps'][0]['checks'][0]
|
||||||
|
self.assertEqual(alpha['kind'], 'alpha')
|
||||||
|
self.assertEqual(alpha['mode'], 'contains')
|
||||||
|
self.assertFalse(alpha['case_sensitive'])
|
||||||
|
sms = script['steps'][1]['checks'][0]
|
||||||
|
self.assertEqual(sms['da'], '12345')
|
||||||
|
self.assertEqual(sms['pid'], {'mode': 'exact', 'value': '7F'})
|
||||||
|
self.assertEqual(sms['dcs'], {'mode': 'exact', 'value': '00'})
|
||||||
|
self.assertEqual(sms['udl'], 5)
|
||||||
|
self.assertEqual(sms['ud'], {'mode': 'mask', 'value': '0271??AABB'})
|
||||||
|
for check in ({'kind': 'alpha'}, # no value
|
||||||
|
{'kind': 'alpha', 'mode': 'nope', 'value': 'x'},
|
||||||
|
{'kind': 'sms'}, # no fields
|
||||||
|
{'kind': 'sms', 'da': '12X'},
|
||||||
|
{'kind': 'sms', 'udl': 256}):
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [
|
||||||
|
{'type': 'expect', 'command': 'ANY', 'checks': [check]}]}, _resolver)
|
||||||
|
|
||||||
|
|
||||||
class TestMatchers(unittest.TestCase):
|
class TestMatchers(unittest.TestCase):
|
||||||
def test_match_value(self):
|
def test_match_value(self):
|
||||||
@@ -883,6 +949,155 @@ class TestRunnerActions(RunnerTestCase):
|
|||||||
self.assertTrue(labels['Files']['ok'], labels)
|
self.assertTrue(labels['Files']['ok'], labels)
|
||||||
self.assertEqual(labels['Files']['actual'], '3F007F106F3A')
|
self.assertEqual(labels['Files']['actual'], '3F007F106F3A')
|
||||||
|
|
||||||
|
# a DISPLAY TEXT carrying the Alpha identifier ('05' = 'Alfa') plus the
|
||||||
|
# text string ('8D' = 'hello') - the alpha check must not read the text
|
||||||
|
DISPLAY_TEXT_ALPHA_CMD = 'D0178103012100820281820504416C66618D060468656C6C6F'
|
||||||
|
# a SEND SHORT MESSAGE with an SMS-SUBMIT: DA 12345, PID 7F, DCS 00,
|
||||||
|
# UDL 5, UD 02 71 00 AA BB
|
||||||
|
SEND_SM_SUBMIT_CMD = 'D01A8103011300820283810B0F010005812143F57F0005027100AABB'
|
||||||
|
|
||||||
|
def test_expect_alpha_and_sms_checks(self):
|
||||||
|
# the Alpha identifier ('05', TS 102 223 8.2) and the SEND SHORT
|
||||||
|
# MESSAGE TPDU fields (an applet's own SMS, not the OTA PoR)
|
||||||
|
scc = FakeScc()
|
||||||
|
scc.push('80F2', '', '9102')
|
||||||
|
scc.push('8012', self.DISPLAY_TEXT_ALPHA_CMD, '9000')
|
||||||
|
scc.push('8014', '', '9000')
|
||||||
|
scc.push('80F2', '', '9102')
|
||||||
|
scc.push('8012', self.SEND_SM_SUBMIT_CMD, '9000')
|
||||||
|
scc.push('8014', '', '9000')
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(buf):
|
||||||
|
run = self.run_script(FakeServer(scc), [
|
||||||
|
{'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
|
||||||
|
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
|
||||||
|
{'type': 'expect', 'command': 'DISPLAY TEXT',
|
||||||
|
'checks': [{'kind': 'alpha', 'mode': 'exact', 'value': 'Alfa'}],
|
||||||
|
'respond': {'result': 'ok'}},
|
||||||
|
{'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
|
||||||
|
'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
|
||||||
|
{'type': 'expect', 'command': 'SEND SHORT MESSAGE',
|
||||||
|
'checks': [{'kind': 'sms', 'da': '12345', 'pid': '7F', 'dcs': '00',
|
||||||
|
'udl': 5, 'ud': '027100AABB'}],
|
||||||
|
'respond': {'result': 'ok'}},
|
||||||
|
])
|
||||||
|
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||||
|
labels = {c['label']: c for c in run['steps'][1]['checks']}
|
||||||
|
self.assertTrue(labels['Alpha']['ok'], labels)
|
||||||
|
self.assertEqual(labels['Alpha']['actual'], 'Alfa')
|
||||||
|
labels = {c['label']: c for c in run['steps'][3]['checks']}
|
||||||
|
for label in ('SMS DA', 'SMS PID', 'SMS DCS', 'SMS UDL', 'SMS UD'):
|
||||||
|
self.assertTrue(labels[label]['ok'], labels)
|
||||||
|
self.assertEqual(run['steps'][3]['sms']['da'], '12345')
|
||||||
|
log = buf.getvalue()
|
||||||
|
self.assertIn('TEST-RUN step 4: SMS DA=12345 PID=7F DCS=00 UDL=5 UD=027100AABB', log)
|
||||||
|
|
||||||
|
def test_expect_sms_check_mismatch_terminates(self):
|
||||||
|
scc = FakeScc()
|
||||||
|
scc.push('80F2', '', '9102')
|
||||||
|
scc.push('8012', self.SEND_SM_SUBMIT_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': 'sms', 'dcs': 'AA'}], 'respond': {'result': 'ok'}},
|
||||||
|
])
|
||||||
|
self.assertNotEqual(run['status'], 'ok')
|
||||||
|
labels = {c['label']: c for c in run['steps'][1]['checks']}
|
||||||
|
self.assertFalse(labels['SMS DCS']['ok'])
|
||||||
|
self.assertEqual(labels['SMS DCS']['expected'], 'AA')
|
||||||
|
self.assertEqual(labels['SMS DCS']['actual'], '00')
|
||||||
|
|
||||||
|
def test_scp80_format_wraps_the_apdu(self):
|
||||||
|
# `format` wraps the C-APDU in the expanded Command Scripting template
|
||||||
|
# (TS 102 226 5.2.1): `expanded` = AA/<len>/22/..., `expanded-ae` =
|
||||||
|
# AE 80 ... 00 00. A compact step keeps the APDU verbatim.
|
||||||
|
for fmt, want in (('', '80E2900000'),
|
||||||
|
('expanded', 'AA07220580E2900000'),
|
||||||
|
('expanded-ae', 'AE80220580E29000000000')):
|
||||||
|
scc = FakeScc()
|
||||||
|
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16,
|
||||||
|
'kidKey': 'BB' * 16, 'counter': '00000001',
|
||||||
|
'tars': preset_tars()}
|
||||||
|
params = {'apdu': '80E2900000'}
|
||||||
|
if fmt:
|
||||||
|
params['format'] = fmt
|
||||||
|
buf = io.StringIO()
|
||||||
|
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}):
|
||||||
|
with contextlib.redirect_stderr(buf):
|
||||||
|
run = self.run_script(FakeServer(scc), [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': params},
|
||||||
|
], preset)
|
||||||
|
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||||
|
self.assertEqual(build.call_args[0][6], want)
|
||||||
|
log = buf.getvalue()
|
||||||
|
if fmt:
|
||||||
|
self.assertIn('TEST-RUN step 1: SCP80 C-APDU=80E2900000 '
|
||||||
|
'WRAPPED=%s (%s)' % (want, fmt), log)
|
||||||
|
self.assertIn('C-APDU=%s format=%s' % (want, fmt),
|
||||||
|
run['steps'][0]['sent'])
|
||||||
|
else:
|
||||||
|
self.assertIn('TEST-RUN step 1: SCP80 C-APDU=80E2900000', log)
|
||||||
|
self.assertNotIn('WRAPPED', log)
|
||||||
|
self.assertNotIn('format=', run['steps'][0]['sent'])
|
||||||
|
|
||||||
|
def test_scp80_inline_por_object_check(self):
|
||||||
|
# `check.por` as an object asserts the decoded PoR (status/SW/data) of
|
||||||
|
# this very exchange - inline here, the SEND SHORT MESSAGE transport
|
||||||
|
# works the same
|
||||||
|
decoded = {'response_status': 'por_ok', 'tar': '000000',
|
||||||
|
'cntr': '0000000A', 'raw': '0271000021AABB',
|
||||||
|
'decoded': {'last_status_word': '9000',
|
||||||
|
'last_response_data': 'AABB'}}
|
||||||
|
preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16,
|
||||||
|
'kidKey': 'BB' * 16, 'counter': '0000000A',
|
||||||
|
'tars': preset_tars()}
|
||||||
|
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
|
||||||
|
mock.patch.object(S, '_send_secured_packet',
|
||||||
|
return_value={'success': True, 'sw': '9000',
|
||||||
|
'response_data': '0271000021AABB'}), \
|
||||||
|
mock.patch.object(S, '_decode_por', return_value=decoded):
|
||||||
|
run = self.run_script(FakeServer(FakeScc()), [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
|
||||||
|
'check': {'por': {'status': 'por_ok', 'sw': '9000',
|
||||||
|
'data': 'AABB'}}},
|
||||||
|
], preset)
|
||||||
|
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||||
|
labels = {c['label']: c for c in run['steps'][0]['checks']}
|
||||||
|
self.assertTrue(labels['PoR status']['ok'], labels)
|
||||||
|
self.assertTrue(labels['PoR SW']['ok'], labels)
|
||||||
|
self.assertTrue(labels['PoR data']['ok'], labels)
|
||||||
|
# a mismatch fails the step; without a decoded PoR the check names it
|
||||||
|
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
|
||||||
|
mock.patch.object(S, '_send_secured_packet',
|
||||||
|
return_value={'success': True, 'sw': '9000',
|
||||||
|
'response_data': '0271000021AABB'}), \
|
||||||
|
mock.patch.object(S, '_decode_por', return_value=decoded):
|
||||||
|
run = self.run_script(FakeServer(FakeScc()), [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
|
||||||
|
'check': {'por': {'status': 'por_error'}}},
|
||||||
|
], preset)
|
||||||
|
self.assertNotEqual(run['status'], 'ok')
|
||||||
|
labels = {c['label']: c for c in run['steps'][0]['checks']}
|
||||||
|
self.assertFalse(labels['PoR status']['ok'])
|
||||||
|
self.assertEqual(labels['PoR status']['actual'], 'por_ok')
|
||||||
|
with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \
|
||||||
|
mock.patch.object(S, '_send_secured_packet',
|
||||||
|
return_value={'success': True, 'sw': '9000',
|
||||||
|
'response_data': None}):
|
||||||
|
run = self.run_script(FakeServer(FakeScc()), [
|
||||||
|
{'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'},
|
||||||
|
'check': {'por': {'status': 'por_ok'}}},
|
||||||
|
], preset)
|
||||||
|
labels = {c['label']: c for c in run['steps'][0]['checks']}
|
||||||
|
self.assertFalse(labels['PoR']['ok'])
|
||||||
|
self.assertIn('no PoR', labels['PoR']['actual'])
|
||||||
|
|
||||||
def test_parse_file_list_splits_concatenated_paths(self):
|
def test_parse_file_list_splits_concatenated_paths(self):
|
||||||
raw = bytes.fromhex('D016810301010082028381120B023F002FE23F007F106F3A')
|
raw = bytes.fromhex('D016810301010082028381120B023F002FE23F007F106F3A')
|
||||||
self.assertEqual(S._parse_file_list(raw), ['3F002FE2', '3F007F106F3A'])
|
self.assertEqual(S._parse_file_list(raw), ['3F002FE2', '3F007F106F3A'])
|
||||||
|
|||||||
Reference in New Issue
Block a user