From abc9ea27515fbd09c9772494422c811009508861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 4 Oct 2026 17:09:06 +0300 Subject: [PATCH] v3.15.0: test-script fixes for applet testing - preset id, kvn, PoR and file-list checks Three gaps blocked SCP80 applet testing from the PWA: - testRunPreset() still marshalled the pre-v3.8 flat preset fields (kic/kid/keys/counter/tar/spi1/spi2, all empty on a stored preset), so every SCP80 run failed with "SCP80 preset is incomplete: no keyset is defined". The PWA sends the matched preset's id now and the server resolves it from the store (_test_run_preset_from_body; the inline `preset` body stays for API callers), so a run always uses the stored keysets, TARs and counters. Regression tests on both sides. - testscript._normalise_params dropped the scp80 step's `kvn` (the editor exposed it and the runner and validation consumed it, so scripts silently used the preset's first keyset). kvn is validated (1-15) and passed through; the client-side problem check bounds it too. - The PoR delivered as a SEND SHORT MESSAGE (PoR-in-submit, SPI2 bit 0x20) is decoded for the expect step that fetches it: the new `por` content check asserts the response status / R-APDU SW / response data, decoded with the preceding scp80 step's context (concatenated SMS accumulate across expectations). The inline transport stays on the action (check.por / check.data). - The new `files` content check asserts the File List of a REFRESH (TS 102 223 8.18, tag 12/92) - e.g. qualifier 00 = NAA initialization with full file change notification - as an order-insensitive set of FID paths. The step editor exposes both checks; help EN/RU, docs/api.md and the new authoring reference docs/test-scripts.md document the format. Tests: 803 frontend + 680 Python; sw cache simple-v378. --- docs/api.md | 19 ++- docs/test-scripts.md | 221 +++++++++++++++++++++++++++++ frontend/help-ru.html | 4 +- frontend/help.html | 4 +- frontend/index.html | 66 ++++++--- frontend/sw.js | 2 +- frontend/tests/testscripts.test.js | 83 +++++++++++ pyproject.toml | 2 +- pysim_simple_server/server.py | 129 ++++++++++++++++- pysim_simple_server/testscript.py | 29 ++++ tests/test_testscript.py | 173 ++++++++++++++++++++++ 11 files changed, 699 insertions(+), 33 deletions(-) create mode 100644 docs/test-scripts.md diff --git a/docs/api.md b/docs/api.md index a444ec4..6d22280 100644 --- a/docs/api.md +++ b/docs/api.md @@ -653,13 +653,19 @@ suspended; only `/api/test/*`, `/api/status`, `/api/poll-status`, {"role": "usimRfm", "tar": "B00001", "msl": "16"}]}} ``` +The PWA sends `preset_id` (the stored preset's id) instead of `preset`; the +server resolves it from the preset store, so a run always uses the stored +keysets, TARs and counters. The inline `preset` form stays for external +callers. + **Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`), `menu-select` (`item_id` 1-255), `file-write` (`path`, `data`, `mode` `auto`/`binary`/`record`, `record`), `file-read` (same, verifies `check.data`), `apdu` (raw transport, no auto-handler), `scp80` (`apdu` or `sp`, selected by -the optional `source` field when both are present; optional `tar`/`spi1`/`spi2` -overrides - KIc/KID and the counter always come from the `preset`, which must -match the equipped card and be complete) or `status` +the optional `source` field when both are present; optional `kvn` (keyset +number 1-15), `tar`/`spi1`/`spi2` overrides - KIc/KID and the counter always +come from the resolved preset, which must match the equipped card and be +complete) or `status` (`attempts`, `interval_ms` - when `attempts > 1` the default SW check is the mask `91??`, i.e. poll until the card announces a command). @@ -672,7 +678,12 @@ previous step (`91XX`); they never poll - a `9000` response means no command and is an error (TS 102 221 7.4.2.1 / TS 102 223 6.3; add a `status` action if the card delivers on poll). `command` is a name or type code; `checks` may be `text` (contains/exact), `item` (`id`/`text` for SELECT ITEM / SET UP -MENU) or `raw` (mask); `respond` is the TERMINAL RESPONSE (`result` name or +MENU), `raw` (mask), `por` (a SEND SHORT MESSAGE carrying the PoR of the +preceding `scp80` step - PoR-in-submit, SPI2 bit `20`: `status` (the response +status name, `ok` = `por_ok`), `sw` (the R-APDU status word, exact/mask) and +`data` (the R-APDU response data, exact/mask)) or `files` (the File List of a +REFRESH, TS 102 223 8.18; `files` is a list of path hex strings, matched +order-insensitively); `respond` is the TERMINAL RESPONSE (`result` name or value, `item_id`, `text`+`dcs`, raw TLVs). The response is the initial state (`running: true`), the final counter diff --git a/docs/test-scripts.md b/docs/test-scripts.md new file mode 100644 index 0000000..10ff9f9 --- /dev/null +++ b/docs/test-scripts.md @@ -0,0 +1,221 @@ +# Test script authoring guide + +A **test script** is a JSON document that drives a deterministic dialogue with +the card over the local server: ordered **actions** (each sends something and +checks the response) and **expectations** (each fetches the proactive command +the card announced, checks it, and sends the scripted TERMINAL RESPONSE). + +Scripts are imported into the PWA (Simulator → Test script → Import) and run +with **Run** against the equipped card, or POSTed directly to +`/api/test/run` (see `docs/api.md`). While a run is active the card belongs to +the script. + +## Document shape + +```json +{"name": "applet RFM update", + "steps": [ + {"type": "action", "kind": "scp80", "params": {…}, "check": {…}, "label": "optional"}, + {"type": "expect", "command": "REFRESH", "qualifier": "00", + "checks": [{"kind": "files", "value": "3F007F206F07"}], + "respond": {"result": "ok"}} + ]} +``` + +Every step may carry `on_fail`: `"error"` (default - stops the run) or +`"warning"` (continues, the report shows ⚠). Each entry of `checks` may +override it with its own `on_fail`. + +## Presets: where the keys, TARs and counters come from + +An SCP80 action never carries key material. The PWA sends the **stored +preset's id** with the run; the server resolves it and takes: + +- the keyset - the step's `kvn` (1–15) or the preset's first keyset, +- the keys and the counter of that keyset, +- the TAR table with each TAR's **MSL** (Minimum SPI1). + +So before a run the matched preset (ICCID) must contain the keyset and, for +every TAR the script addresses, either a TAR entry with its MSL or an explicit +`spi1` in the step. Counter handling: the run starts from the keyset's stored +counter and persists each accepted packet's next value, so consecutive runs +continue correctly. + +## Actions + +```json +{"type": "action", "kind": "…", "params": {…}, "check": {…}} +``` + +| kind | params | notes | +|---|---|---| +| `envelope` | `event` 0–255, `data` hex (optional) | ENVELOPE(Event Download) | +| `menu-select` | `item_id` 1–255 | ENVELOPE(Menu Selection) | +| `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD | +| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` | +| `apdu` | `apdu` hex | raw transport, no auto-handler | +| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2` | secured packet to the TAR | +| `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) | + +`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs +or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`. + +### `check` on actions + +```json +"check": {"sw": {"mode": "mask", "value": "91??"}, + "data": {"mode": "exact", "value": "0271…"}, + "por": "ok"} +``` + +- `sw` - default `{"mode": "exact", "value": "9000"}` (a `status` action with + `attempts > 1` defaults to the `91??` mask). +- `data` - the response data (for SCP80: the inline PoR packet). +- `por` - `"any"` (default, no check), `"ok"` (`response_status == por_ok`), + `"none"` (no PoR expected). +- A plain hex string is exact; a value containing `?` is a mask. **A mask + must have exactly the same length as the actual value** (`?` is a per-nibble + wildcard) - e.g. `91??` matches `9102`, `027100????` matches a 4-byte + prefix. + +## Expectations + +```json +{"type": "expect", "command": "SEND SHORT MESSAGE", + "qualifier": "00", + "checks": [{"kind": "por", "status": "por_ok", "sw": "9000"}], + "respond": {"result": "ok"}} +``` + +The card must have announced a command (`91XX`) in the previous step - the +runner never polls by itself. `command` is a name (as shown in the proactive +log) or a hex type; `ANY`/`*` skips the type check. `qualifier` is the +command-details byte 3 (one byte, exact/mask). + +### Content checks + +| kind | fields | checks | +|---|---|---| +| `text` | `mode` contains/exact, `value`, `case_sensitive` | alpha identifier / text | +| `item` | `id`, `text`, `mode`, `case_sensitive` | items of SELECT ITEM / SET UP MENU | +| `raw` | `mode` exact/mask, `value` | the whole fetched command (hex, exact length needed) | +| `por` | `status`, `sw`, `data` | the PoR carried by a **SEND SHORT MESSAGE** (see below) | +| `files` | `files` (list of path hex strings) or a comma/space separated string | the **File List** of a REFRESH, TS 102 223 §8.18; matched order-insensitively | + +`por` needs an SCP80 step **before** the expectation: the TPDU of the SEND +SHORT MESSAGE is decoded with that step's keyset, SPI and counter. `status` +is the decoded response status name (`por_ok`, `cntr_low`, +`rc_cc_ds_failed`, …; `ok` is accepted for `por_ok`); `sw` is the R-APDU +status word and `data` the R-APDU response data (exact/mask) - both come from +the decoded response (scripting `AB`/`AF`/compact forms). A PoR split over +several SMS parts is accumulated across SEND SHORT MESSAGE expectations: put +the `por` check on the step that completes the sequence (intermediate steps +should use `raw` or no PoR check). + +### `respond` + +The TERMINAL RESPONSE for the fetched command: `result` (name or hex - +`ok`, `partial`, `missing`, `refused`, `not_understood`, `modified`, `cancel`, +`back`, `timeout`, `no_response`), `item_id`, `text`+`dcs`, `raw` (extra +COMPREHENSION-TLVs before the result). + +## The two PoR transports (SPI2) + +1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the + response data. Verify on the action: `"por": "ok"` and/or + `"data": {"mode": "mask", "value": "…"}`. +2. **PoR-in-submit** (SPI2 bit `20`, e.g. `spi2: "21"`) - the card announces + a **SEND SHORT MESSAGE** (`91XX`); the PoR is inside its SMS TPDU: + +```json +{"type": "action", "kind": "scp80", + "params": {"apdu": "…", "tar": "B00001", "spi2": "21"}, + "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}}, +{"type": "expect", "command": "SEND SHORT MESSAGE", + "checks": [{"kind": "por", "status": "por_ok", "sw": "9000"}], + "respond": {"result": "ok"}} +``` + +If the card announces the SEND SHORT MESSAGE only on a later STATUS poll, +insert a `status` action (`attempts` > 1) before the expectation. + +## "No actions" after a trigger + +An exact SW check is the assertion: `"check": {"sw": "9000"}` fails if the +card answers `91XX` (a command was announced); the runner also errors on a +pending command the next step does not expect. + +## Worked examples + +### RFM update → file change → REFRESH (NAA init + full FCN) + +```json +{"name": "applet RFM update", + "steps": [ + {"type": "action", "kind": "scp80", + "params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "01"}, + "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}, + "label": "RFM update"}, + {"type": "expect", "command": "REFRESH", "qualifier": "00", + "checks": [{"kind": "files", "value": "3F007F206F07"}], + "respond": {"result": "ok"}}, + {"type": "action", "kind": "file-read", "params": {"path": "MF/7F20/6F07"}, + "check": {"data": {"mode": "mask", "value": "AA??CC"}}} + ]} +``` + +`qualifier` `00` is "NAA Initialization and Full File Change Notification"; +the `files` check asserts the exact set of changed files the applet reports. +For this mode the terminal must **not** reset the card +(TS 102 223 §6.4.7), so the run continues - the following `file-read` +verifies the updated content. + +### Incoming data → particular PoR, no other actions + +Inline PoR: + +```json +{"name": "applet data (inline PoR)", + "steps": [ + {"type": "action", "kind": "scp80", + "params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "01"}, + "check": {"sw": "9000", "por": "ok", + "data": {"mode": "mask", "value": "027100????0102"}}} + ]} +``` + +PoR-in-submit (with the transport asserted by the expectation): + +```json +{"name": "applet data (SEND SM PoR)", + "steps": [ + {"type": "action", "kind": "scp80", + "params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "21"}, + "check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}}, + {"type": "expect", "command": "SEND SHORT MESSAGE", + "checks": [{"kind": "por", "status": "por_ok"}], + "respond": {"result": "ok"}} + ]} +``` + +## Run errors worth knowing + +- `SCP80 preset is incomplete: …` - the matched preset has no complete + keyset; `step N: keyset K is not defined in the preset` - the step's `kvn` + is outside the preset's keysets. +- `no MSL for TAR … - set it in the Cards tab or pass spi1` - the step's TAR + has no entry in the preset and the step carries no `spi1`. +- `no proactive command pending …` - an expectation ran without a `91XX` from + the previous step (add a `status` action when the card delivers on poll). +- `card verdict: no PoR (the card sent none for SPI2 …)` - an SCP80 operation + expected a PoR that never arrived (check SPI2 and the transport). + +## Known limitations + +- The runner executes one command at a time and never polls by itself; a + proactive command that arrives only on a STATUS poll needs an explicit + `status` action. +- A `por` check only decodes SEND SHORT MESSAGE commands; the inline transport + is checked on the action (`por`/`data`). +- `files` matches the File List as a set (order-insensitive); duplicate paths + are kept. diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 2eeafa9..ac154ea 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -563,8 +563,8 @@

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

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

diff --git a/frontend/help.html b/frontend/help.html index eb3591d..2db359c 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -562,8 +562,8 @@

8.9 Test script

A test script is an ordered list of actions and proactive-command expectations executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until Clear report. Scripts live in localStorage (simple_tests) and export/import as JSON.

diff --git a/frontend/index.html b/frontend/index.html index 37d8c3f..36e1f75 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1877,7 +1877,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.14.2'; +const SIMPLE_VERSION = '3.15.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -6946,6 +6946,10 @@ function testScriptProblem(script) { const src = p.source || (p.sp ? 'sp' : 'apdu'); const value = src === 'sp' ? p.sp : p.apdu; if (!String(value || '').trim()) return at + t('SCP80 needs an APDU or a secured packet'); + if (p.kvn !== undefined && String(p.kvn).trim() !== '') { + const kvn = parseInt(String(p.kvn).trim(), 10); + if (!(kvn >= 1 && kvn <= 15)) return at + t('keyset number must be 1..15'); + } } if (s.kind === 'menu-select' && !(p.item_id >= 1 && p.item_id <= 255)) return at + t('item id must be 1..255'); if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty'); @@ -6968,6 +6972,14 @@ function testCheckSummary(c) { return s; } if (c.kind === 'raw') return 'raw ' + (c.mode === 'mask' ? '~' : '=') + (c.value || ''); + if (c.kind === 'por') { + const parts = []; + if (c.status) parts.push('status ' + c.status); + if (c.sw) parts.push('SW ' + (c.sw.mode === 'mask' ? '~' : '') + c.sw.value); + if (c.data) parts.push('data ' + (c.data.mode === 'mask' ? '~' : '') + c.data.value); + return 'por ' + parts.join(' '); + } + if (c.kind === 'files') return 'files ' + (c.files || []).join(' '); return ''; } @@ -7330,7 +7342,8 @@ function testRenderChecks() { _testEditChecks.forEach((c, i) => { html += '
' + testFormSelect('test-check-kind-' + i, c.kind || 'text', [ - {v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')}], 'testCheckKindChanged()'); + {v: 'text', l: t('Text')}, {v: 'item', l: t('Item')}, {v: 'raw', l: t('Raw')}, + {v: 'por', l: t('PoR')}, {v: 'files', l: t('Files')}], 'testCheckKindChanged()'); if (c.kind === 'item') { html += testFormInput('test-check-id-' + i, c.id, t('id')); html += testFormInput('test-check-text-' + i, c.text, t('text')); @@ -7339,6 +7352,12 @@ function testRenderChecks() { } else if (c.kind === 'raw') { html += testFormSelect('test-check-mode-' + i, c.mode || 'mask', [{v: 'exact', l: t('Exact')}, {v: 'mask', l: t('Mask ("?" = wildcard)')}]); html += testFormInput('test-check-value-' + i, c.value, 'AA??'); + } else if (c.kind === 'por') { + html += testFormInput('test-check-porstatus-' + i, c.status, 'por_ok'); + html += testFormInput('test-check-por-sw-' + i, c.sw && c.sw.value, '6A88'); + html += testFormInput('test-check-por-data-' + i, c.data && c.data.value, 'AA??'); + } else if (c.kind === 'files') { + html += testFormInput('test-check-files-' + i, (c.files || []).join(', '), '3F007F106F3A'); } else { html += testFormSelect('test-check-mode-' + i, c.mode || 'contains', [{v: 'contains', l: t('Contains')}, {v: 'exact', l: t('Exact')}]); html += testFormInput('test-check-value-' + i, c.value, t('text')); @@ -7383,6 +7402,16 @@ function testChecksCollect() { } else if (kind === 'raw') { row.mode = val('test-check-mode-' + i) || 'mask'; row.value = val('test-check-value-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase(); + } else if (kind === 'por') { + const status = val('test-check-porstatus-' + i); + if (status) row.status = status; + const sw = val('test-check-por-sw-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase(); + if (sw) row.sw = {mode: sw.includes('?') ? 'mask' : 'exact', value: sw}; + const data = val('test-check-por-data-' + i).replace(/[^0-9a-fA-F?]/g, '').toUpperCase(); + if (data) row.data = {mode: data.includes('?') ? 'mask' : 'exact', value: data}; + } else if (kind === 'files') { + row.files = val('test-check-files-' + i).split(/[,\s]+/) + .map(s => s.replace(/[^0-9a-fA-F]/g, '').toUpperCase()).filter(Boolean); } else { row.mode = val('test-check-mode-' + i) || 'contains'; row.value = val('test-check-value-' + i); @@ -7450,6 +7479,8 @@ function testStepCollect() { step.checks = JSON.parse(JSON.stringify(_testEditChecks)).filter(c => { if (c.kind === 'text') return !!(c.value || '').trim(); if (c.kind === 'item') return c.id || (c.text || '').trim(); + if (c.kind === 'por') return !!(c.status || c.sw || c.data); + if (c.kind === 'files') return !!(c.files && c.files.length); return !!(c.value || '').trim(); }); const r = {result: val('test-f-result') || 'ok'}; @@ -7567,34 +7598,28 @@ function testRunStepHtml(step) { function testRunPreset() { const preset = cardsMatchedPreset(); if (!preset) return {error: t('No card preset matches the equipped card - SCP80 steps need one')}; - if (!cardsScp80Complete(preset)) return {error: t('The matched card preset has incomplete SCP80 data (KIc/KID, SPI1/SPI2, counter, keys)')}; + if (!cardsScp80Complete(preset)) return {error: t('The matched card preset has incomplete SCP80 data (a complete keyset and the role TARs with their MSLs)')}; _testLastPresetIdx = cards.indexOf(preset); - return {preset: { - name: preset.name || '', - iccid: preset.iccid || '', - kic: String(preset.kic || '').toUpperCase(), - kid: String(preset.kid || '').toUpperCase(), - kicKey: String(preset.kicKey || '').replace(/\s/g, '').toUpperCase(), - kidKey: String(preset.kidKey || '').replace(/\s/g, '').toUpperCase(), - counter: String(preset.cntr || '').replace(/\s/g, '').toUpperCase(), - tar: String(preset.tar || '').replace(/\s/g, '').toUpperCase(), - spi1: String(preset.spi1 || '').replace(/\s/g, '').toUpperCase(), - spi2: String(preset.spi2 || '').replace(/\s/g, '').toUpperCase(), - }}; + // The run names the preset; the server resolves it from the store, so the + // run uses the stored keysets, TARs and counters (the flat preset fields + // this used to send no longer exist - the run failed with "no keyset"). + return {preset_id: preset.id}; } async function testRunStart() { const cur = testCurrent(); const problem = testScriptProblem(cur); if (problem) { testRunMessage(problem, 'error'); return; } - let preset = null; + let presetId = null; if (cur.steps.some(s => s.type === 'action' && s.kind === 'scp80')) { const found = testRunPreset(); if (found.error) { testRunMessage(found.error, 'error'); return; } - preset = found.preset; + presetId = found.preset_id; } try { - const resp = await pysimFetch('/api/test/run', {script: cur, preset: preset}); + const body = {script: cur}; + if (presetId) body.preset_id = presetId; + const resp = await pysimFetch('/api/test/run', body); if (resp.error) { testRunMessage(resp.error, 'error'); return; } testRenderRun(resp); testStartRunTimer(); @@ -20904,6 +20929,9 @@ const LANG_RU = { 'Contains': 'Содержит', 'Item': 'Пункт', 'Raw': 'Raw', + 'PoR': 'PoR', + 'Files': 'Файлы', + 'keyset number must be 1..15': 'номер keyset должен быть 1..15', 'Test script running:': 'Выполняется тест-скрипт:', 'Test script running': 'Выполняется тест-скрипт', 'Preset': 'Пресет', @@ -20913,7 +20941,7 @@ const LANG_RU = { 'running': 'выполняется', 'Received': 'Получено', 'No card preset matches the equipped card - SCP80 steps need one': 'Ни один пресет карты не соответствует подключённой карте — для шагов SCP80 он нужен', - 'The matched card preset has incomplete SCP80 data (KIc/KID, SPI1/SPI2, counter, keys)': 'У подобранного пресета неполные данные SCP80 (KIc/KID, SPI1/SPI2, счётчик, ключи)', + 'The matched card preset has incomplete SCP80 data (a complete keyset and the role TARs with their MSLs)': 'У подобранного пресета неполные данные SCP80 (полный keyset и ролевые TAR с их MSL)', 'SCP80 counter saved to the preset:': 'Счётчик SCP80 сохранён в пресет:', 'STK menu': 'Меню STK', 'No menu set by the card': 'Меню не задано картой', diff --git a/frontend/sw.js b/frontend/sw.js index 3071e92..d3fe118 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v377'; +const CACHE = 'simple-v378'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js index bb6fc7e..ff5b595 100644 --- a/frontend/tests/testscripts.test.js +++ b/frontend/tests/testscripts.test.js @@ -44,6 +44,7 @@ eval(extractFunc(html, 'testStepRender')); eval(extractFunc(html, 'testRenderChecks')); eval(extractFunc(html, 'testStepCollect')); eval(extractFunc(html, 'testWriteBackCounter')); +eval(extractFunc(html, 'testRunPreset')); eval(extractFunc(html, 'testChecksCollect')); eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;' + ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;' @@ -266,3 +267,85 @@ test('the Simulator hosts the Test script pill and its wiring', () => { assert.match(html, /\/api\/test\/status/); assert.match(html, /id="test-step-modal"/); }); + +test('the run sends the stored preset id, not the removed flat fields', () => { + const preset = { id: 'p1', name: 'Card 1', + keysets: [{ kic: '15', kid: '15', kicKey: 'AA'.repeat(16), + kidKey: 'BB'.repeat(16), cntr: '0000000A' }], + tars: [{ role: 'isd', tar: '000000', msl: '16' }] }; + globalThis.cards = [preset]; + globalThis.cardsMatchedPreset = () => preset; + globalThis.cardsScp80Complete = () => true; + const found = testRunPreset(); + // the preset body used to carry flat kic/kid/... fields that no longer + // exist on a stored preset - the server resolved an empty keyset and + // refused every SCP80 run + assert.deepStrictEqual(found, { preset_id: 'p1' }); + assert.strictEqual(_testLastPresetIdx, 0); +}); + +test('the run refuses without a matched or complete preset', () => { + globalThis.cards = []; + globalThis.cardsMatchedPreset = () => null; + assert.match(testRunPreset().error, /No card preset matches/); + const preset = { id: 'p2' }; + globalThis.cards = [preset]; + globalThis.cardsMatchedPreset = () => preset; + globalThis.cardsScp80Complete = () => false; + assert.match(testRunPreset().error, /incomplete SCP80/); +}); + +test('the client-side problem check bounds the keyset number', () => { + assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', + params: { apdu: '80E2', kvn: 2 } }] }), ''); + assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', + params: { apdu: '80E2', kvn: 16 } }] }), /keyset number/); + assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80', + params: { apdu: '80E2', kvn: 0 } }] }), /keyset number/); +}); + +test('the PoR and file-list checks render and collect', () => { + const els = { 'test-checks': {} }; + globalThis.document = { getElementById: id => els[id] || null }; + _testEditChecks = [{ kind: 'por', status: 'por_ok', + sw: { mode: 'mask', value: '6A8?' }, on_fail: 'error' }]; + testRenderChecks(); + const rendered = els['test-checks'].innerHTML; + assert.ok(rendered.includes('value="por" selected'), rendered); + assert.match(rendered, /id="test-check-porstatus-0"/); + assert.match(rendered, /id="test-check-por-sw-0"/); + + const values = { + 'test-check-kind-0': 'por', + 'test-check-porstatus-0': 'por_ok', + 'test-check-por-sw-0': '6A8?', + 'test-check-por-data-0': 'aabb', + 'test-check-fail-0': 'warning', + }; + globalThis.document = { getElementById: id => (id in values ? { value: values[id] } : null) }; + testChecksCollect(); + assert.deepStrictEqual(_testEditChecks[0], { + kind: 'por', on_fail: 'warning', status: 'por_ok', + sw: { mode: 'mask', value: '6A8?' }, + data: { mode: 'exact', value: 'AABB' }, + }); + + _testEditChecks = [{ kind: 'files', files: ['3F007F106F3A'], on_fail: 'error' }]; + globalThis.document = { getElementById: id => els[id] || null }; + testRenderChecks(); + assert.match(els['test-checks'].innerHTML, /id="test-check-files-0"/); + const values2 = { 'test-check-kind-0': 'files', + 'test-check-files-0': '3F007F106F3A, 3f002fe2', 'test-check-fail-0': 'error' }; + globalThis.document = { getElementById: id => (id in values2 ? { value: values2[id] } : null) }; + testChecksCollect(); + assert.deepStrictEqual(_testEditChecks[0], + { kind: 'files', on_fail: 'error', files: ['3F007F106F3A', '3F002FE2'] }); +}); + +test('the check summary names the PoR and file-list checks', () => { + assert.strictEqual(testCheckSummary({ kind: 'por', status: 'por_ok', + sw: { mode: 'mask', value: '6A8?' }, data: { mode: 'exact', value: 'AABB' } }), + 'por status por_ok SW ~6A8? data AABB'); + assert.strictEqual(testCheckSummary({ kind: 'files', files: ['3F002FE2'] }), + 'files 3F002FE2'); +}); diff --git a/pyproject.toml b/pyproject.toml index 54153cc..4637cf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.14.2" +version = "3.15.0" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 7d12a26..f7086d4 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -32,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.14.2' +VERSION = '3.15.0' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -5870,6 +5870,12 @@ def _test_run_scp80(server, step, ctx): counters[kvn] = (keyset or {}).get('cntr') or '00000000' counter = counters.get(kvn) if kvn else (ctx.get('counter') or '00000000') counter = counter or '00000000' + # The PoR of this exchange may arrive as a SEND SHORT MESSAGE (PoR-in- + # submit); the expect step decodes it with the parameters the packet was + # built from, so remember them. + ctx['last_scp80'] = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid, + 'counter': counter, 'kicKey': kic_key, 'kidKey': kid_key, + 'tar': tar} if p.get('sp'): sp_hex = p['sp'] source = 'sp' @@ -5943,7 +5949,82 @@ def _test_run_action(server, step, ctx): return result, pending -def _test_run_expect(server, step, pending): +def _test_run_preset_from_body(server, body): + """The preset of a run: the inline `preset` body (external API callers, + docs/api.md) or the stored preset named by `preset_id` - the PWA sends the + id, so the run always uses the store's authoritative keys/TARs/counters. + Returns ``(preset, error)``; error is None on success.""" + preset = body.get('preset') or {} + if preset: + return preset, None + pid = str(body.get('preset_id') or '').strip() + if not pid: + return {}, None + preset = _preset_by_id(server, pid) + if preset is None: + return None, 'card preset %s not found' % pid + return preset, None + + +def _parse_file_list(raw): + """The File List (TS 102 223 8.18; tag 12/92 per TS 101 220) of a fetched + proactive command, as a list of path hex strings; [] when absent. The + value is the number of files followed by concatenated 2-byte FIDs - every + path starts with the MF ('3F'), which delimits the entries.""" + try: + value = _cmd_tlv(httpota.proactive_tlvs(raw), 0x12) + except Exception: + return [] + if not value: + return [] + data = value[1:] + paths = [] + cur = bytearray() + for i in range(0, len(data) - len(data) % 2, 2): + fid = data[i:i + 2] + if len(fid) < 2: + break + if fid[0] == 0x3F and cur: + paths.append(bytes(cur).hex().upper()) + cur = bytearray() + cur += fid + if cur: + paths.append(bytes(cur).hex().upper()) + return paths + + +def _test_expect_por(ctx, raw): + """Decode the PoR carried by a fetched SEND SHORT MESSAGE command (the + PoR-in-submit transport, SPI2 bit 0x20, TS 102 225 5.1.1), using the + context of the scp80 step that triggered it. None when the command is + not a PoR or no scp80 step ran.""" + last = (ctx or {}).get('last_scp80') + if not last: + return None + tpdu_hex = _find_sms_tpdu(raw) + if not tpdu_hex: + return None + try: + ref, total, num, payload = _parse_sms_concat(bytes.fromhex(tpdu_hex)) + except Exception: + return None + if total is not None and num is not None and total > 1: + segs = ctx.setdefault('por_segments', []) + segs.append((ref, total, num, payload.hex())) + matching = sorted([s for s in segs if s[0] == ref], key=lambda s: s[2]) + if len(matching) < total: + return None + ud_hex = ''.join(s[3] for s in matching) + else: + ud_hex = payload.hex() + if not ud_hex: + return None + por_hex = '027100' + ud_hex + return _decode_por(last['spi1'], last['spi2'], last['kic'], last['kid'], + last['counter'], last['kicKey'], last['kidKey'], por_hex) + + +def _test_run_expect(server, step, pending, ctx=None): scc = server.scc if not pending: raise testscript.ScriptError( @@ -5957,6 +6038,10 @@ def _test_run_expect(server, step, pending): cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) log_entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num) type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN') + # A SEND SHORT MESSAGE carries the PoR when the packet asked for it in + # submit mode (SPI2 bit 0x20): decode it with the triggering scp80 step's + # context so the checks can assert the PoR contents. + por = _test_expect_por(ctx, raw) if cmd_type == 0x13 else None checks = [] want_type = step['command'].get('type') type_ok = want_type is None or cmd_type == want_type @@ -6000,6 +6085,36 @@ def _test_run_expect(server, step, pending): checks.append(_test_check_result('Raw', testscript.match_value({'mode': c['mode'], 'value': c['value']}, actual), c['value'], actual, c['on_fail'])) + elif c['kind'] == 'por': + if por is None: + checks.append(_test_check_result( + 'PoR', False, 'a decoded PoR', + 'no PoR in this command (or no scp80 step before it)', + c['on_fail'])) + else: + if 'status' in c: + actual = str(por.get('response_status') or '') + want = c['status'] + ok = (actual.lower() == want.lower() + or (want.lower() == 'ok' and actual == 'por_ok')) + checks.append(_test_check_result('PoR status', ok, want, + actual or '(none)', c['on_fail'])) + if 'sw' in c: + actual = str((por.get('decoded') or {}).get('last_status_word') or '') + checks.append(_test_check_result( + 'PoR SW', testscript.match_value(c['sw'], actual), + c['sw']['value'], actual or '(none)', c['on_fail'])) + if 'data' in c: + actual = str((por.get('decoded') or {}).get('last_response_data') or '') + checks.append(_test_check_result( + 'PoR data', testscript.match_value(c['data'], actual), + c['data']['value'], actual or '(none)', c['on_fail'])) + elif c['kind'] == 'files': + paths = _parse_file_list(raw) + want = c['files'] + checks.append(_test_check_result( + 'Files', sorted(paths) == sorted(want), ', '.join(want), + ', '.join(paths) or '(none)', c['on_fail'])) tr = testscript.build_tr(cmd_num, cmd_type, dev_dst, dev_src, step['respond']) tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex())) tr_sw = tr_rv[1] @@ -6007,6 +6122,7 @@ def _test_run_expect(server, step, pending): status = testscript.combine_levels([c['level'] if not c['ok'] else 'ok' for c in checks]) result = {'status': status, 'sw': tr_sw, 'data': raw.hex().upper(), 'sent': 'TR ' + tr.hex().upper(), 'checks': checks, + 'por': por, 'command': {'type_hex': '%02X' % cmd_type, 'type_name': type_name, 'qualifier': '%02X' % cmd_qual if cmd_qual is not None else None, 'raw': raw.hex().upper()}} @@ -6080,7 +6196,7 @@ def _test_run_execute(server, script, preset): if step['type'] == 'action': result, pending = _test_run_action(server, step, ctx) else: - result, pending = _test_run_expect(server, step, pending) + result, pending = _test_run_expect(server, step, pending, ctx) _test_finish_entry(entry, result) if entry['status'] == 'error': # Error terminates the script; Warning and OK continue. @@ -8048,7 +8164,12 @@ class PysimHandler(BaseHTTPRequestHandler): self._send_json(resp, 400) self._log_resp(resp) return - preset = body.get('preset') or {} + preset, preset_err = _test_run_preset_from_body(self.server, body) + if preset_err: + resp = {'error': preset_err} + self._send_json(resp, 400) + self._log_resp(resp) + return needs_scp80 = any(s['type'] == 'action' and s['kind'] == 'scp80' for s in script['steps']) err = _test_preset_error(script, preset) if needs_scp80 else None diff --git a/pysim_simple_server/testscript.py b/pysim_simple_server/testscript.py index 3742535..8ed409b 100644 --- a/pysim_simple_server/testscript.py +++ b/pysim_simple_server/testscript.py @@ -253,6 +253,10 @@ def _normalise_params(kind, p): for key in ('spi1', 'spi2'): if out.get(key) and len(out[key]) != 2: raise ScriptError('scp80: %s must be 1 byte' % key) + # The keyset number (KIc/KID b8..b5, TS 102 225 5.1.2): the keys and + # the counter stay preset-owned, the step only picks which keyset. + if p.get('kvn') not in (None, ''): + out['kvn'] = _int(p.get('kvn'), 'scp80 keyset number', 1, 15) return out if kind == 'status': attempts = p.get('attempts') @@ -355,6 +359,31 @@ def _normalise_content_check(c, default_level): spec = _check_spec(c.get('value') if 'value' in c else c, 'raw check') return {'kind': 'raw', 'mode': spec['mode'], 'value': spec['value'], 'on_fail': level} + if kind == 'por': + out = {'kind': 'por', 'on_fail': level} + status = c.get('status') + if status is not None and str(status).strip(): + out['status'] = str(status).strip() + if c.get('sw') not in (None, ''): + out['sw'] = _check_spec(c.get('sw'), 'por check sw') + if c.get('data') not in (None, ''): + out['data'] = _check_spec(c.get('data'), 'por check data') + if not (out.get('status') or out.get('sw') or out.get('data')): + raise ScriptError('por check: status, sw or data is required') + return out + if kind == 'files': + val = c.get('files', c.get('value')) + if isinstance(val, str): + val = [x for x in re.split(r'[,\s]+', val) if x] + if not isinstance(val, list) or not val: + raise ScriptError('files check: a list of file paths is required') + files = [] + for f in val: + path = re.sub(r'\s', '', str(f)).upper() + if not re.fullmatch(r'(?:[0-9A-F]{2})+', path) or len(path) < 4: + raise ScriptError('files check: each path must be hex FID bytes: %r' % f) + files.append(path) + return {'kind': 'files', 'files': files, 'on_fail': level} raise ScriptError('unknown check kind %r' % c.get('kind')) diff --git a/tests/test_testscript.py b/tests/test_testscript.py index 57262ec..38aaed6 100644 --- a/tests/test_testscript.py +++ b/tests/test_testscript.py @@ -104,6 +104,40 @@ class TestValidation(unittest.TestCase): '8103042300' + '82028182' + 'AA01BB' + '8D0600' + '68656C6C6F' + '83020000') + def test_scp80_keyset_number_and_new_content_checks(self): + # kvn (1..15) selects the preset's keyset; `por`/`files` are expect + # content checks (the PoR contents / the REFRESH file list). + script = T.normalise_script({'steps': [ + {'type': 'action', 'kind': 'scp80', + 'params': {'apdu': '80E2900000', 'kvn': 2}}, + {'type': 'expect', 'command': 'SEND SHORT MESSAGE', + 'checks': [{'kind': 'por', 'status': 'por_ok', 'sw': '6A8?', + 'data': {'mode': 'mask', 'value': 'AA??'}}]}, + {'type': 'expect', 'command': 'REFRESH', + 'checks': [{'kind': 'files', 'value': '3F007F106F3A, 3F002FE2'}]}, + ]}, _resolver) + self.assertEqual(script['steps'][0]['params']['kvn'], 2) + por = script['steps'][1]['checks'][0] + self.assertEqual(por['status'], 'por_ok') + self.assertEqual(por['sw'], {'mode': 'mask', 'value': '6A8?'}) + self.assertEqual(por['data'], {'mode': 'mask', 'value': 'AA??'}) + self.assertEqual(script['steps'][2]['checks'][0]['files'], + ['3F007F106F3A', '3F002FE2']) + # a keyset number outside 1..15, an empty por check and a bad path are + # refused before anything runs + with self.assertRaises(T.ScriptError): + T.normalise_script({'steps': [ + {'type': 'action', 'kind': 'scp80', + 'params': {'apdu': '80E2', 'kvn': 0}}]}, _resolver) + with self.assertRaises(T.ScriptError): + T.normalise_script({'steps': [ + {'type': 'expect', 'command': 'ANY', + 'checks': [{'kind': 'por'}]}]}, _resolver) + with self.assertRaises(T.ScriptError): + T.normalise_script({'steps': [ + {'type': 'expect', 'command': 'ANY', + 'checks': [{'kind': 'files', 'value': ['zz']}]}]}, _resolver) + class TestMatchers(unittest.TestCase): def test_match_value(self): @@ -424,6 +458,65 @@ class TestRunnerActions(RunnerTestCase): self.assertEqual(args[4], 'B00001') # tar override self.assertEqual(args[2], '15') # kic stays preset-owned + def test_scp80_step_selects_the_keyset_by_kvn(self): + # the step's keyset number picks the keyset (v3.15.0: the normaliser + # dropped `kvn`, so scripts silently used the preset's first keyset) + scc = FakeScc() + preset = {'keysets': [ + {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, + 'kidKey': 'BB' * 16, 'cntr': '00000001'}, + {'kic': '25', 'kid': '25', 'kicKey': 'CC' * 16, + 'kidKey': 'DD' * 16, 'cntr': '00000020'}], + 'tars': preset_tars()} + with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})) as build, \ + mock.patch.object(S, '_send_secured_packet', + return_value={'success': True, 'sw': '9000', + 'response_data': None}): + run = self.run_script(FakeServer(scc), [ + {'type': 'action', 'kind': 'scp80', + 'params': {'apdu': '80E2900000', 'kvn': 2}}, + ], preset) + self.assertEqual(run['status'], 'ok', run['steps']) + args = build.call_args[0] + self.assertEqual(args[2], '25') # the second keyset's KIc/KID + self.assertEqual(args[3], '25') + self.assertEqual(args[5], '00000020') # and its counter + self.assertEqual(args[7], 'CC' * 16) + self.assertEqual(args[8], 'DD' * 16) + self.assertEqual(run['scp80_counters'], {2: '00000021'}) + self.assertEqual(run['scp80_counter'], '00000021') + + def test_preset_completeness_validates_the_step_keyset(self): + script = T.normalise_script({'steps': [ + {'type': 'action', 'kind': 'scp80', + 'params': {'apdu': '80E2900000', 'kvn': 3}}]}, S._test_command_type) + preset = {'keysets': [{'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB', + 'cntr': '00000000'}], + 'tars': preset_tars()} + self.assertIn('keyset 3', S._test_preset_error(script, preset) or '') + good = T.normalise_script({'steps': [ + {'type': 'action', 'kind': 'scp80', + 'params': {'apdu': '80E2900000', 'kvn': 1}}]}, S._test_command_type) + self.assertIsNone(S._test_preset_error(good, preset)) + + def test_run_preset_resolves_the_stored_id(self): + class Store: + def get(self, pid): + return {'id': pid, 'name': 'Card 1'} if pid == 'p1' else None + + import types + server = types.SimpleNamespace(card_presets=Store()) + preset, err = S._test_run_preset_from_body(server, {'preset_id': 'p1'}) + self.assertEqual(preset['name'], 'Card 1') + self.assertIsNone(err) + preset, err = S._test_run_preset_from_body(server, {'preset_id': 'nope'}) + self.assertIsNone(preset) + self.assertIn('not found', err) + inline = {'name': 'inline'} + preset, err = S._test_run_preset_from_body(server, {'preset': inline}) + self.assertIs(preset, inline) + self.assertIsNone(err) + def test_preset_completeness_is_validated(self): script = T.normalise_script({'steps': [ {'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'}}, @@ -435,6 +528,86 @@ class TestRunnerActions(RunnerTestCase): no_tar = dict(full, tars=[]) self.assertIn('TAR', S._test_preset_error(script, no_tar) or '') + # A SEND SHORT MESSAGE announcing the PoR-in-submit (SPI2 bit 0x20): the + # TPDU carries a submit PDUs with UD = 02 71 00 AA BB (the deliver-style + # PoR header); the runner must decode it with the scp80 step's context. + SEND_SM_POR_CMD = 'D0188103011300820283810B0D01000181127F0005027100AABB' + # A REFRESH (qualifier 00 = NAA init + full FCN) with the file list + # 12 07 01 3F007F106F3A (one file: MF/7F10/6F3A). + REFRESH_FCN_CMD = 'D0128103010100820283811207013F007F106F3A' + + def test_expect_decodes_the_submit_por(self): + scc = FakeScc() + preset = {'kic': '15', 'kid': '15', 'kicKey': 'AA' * 16, 'kidKey': 'BB' * 16, + 'counter': '0000000A', 'tars': preset_tars()} + decoded = {'response_status': 'por_ok', 'tar': '000000', + 'decoded': {'last_status_word': '9000', 'last_response_data': 'AABB'}} + with mock.patch.object(S, '_build_secured_packet', return_value=('AA' * 10, {})), \ + mock.patch.object(S, '_send_secured_packet', + return_value={'success': True, 'sw': '9102', + 'response_data': None}), \ + mock.patch.object(S, '_decode_por', return_value=decoded) as dec: + scc.push('8012', self.SEND_SM_POR_CMD, '9000') + scc.push('8014', '', '9000') + run = self.run_script(FakeServer(scc), [ + {'type': 'action', 'kind': 'scp80', 'params': {'apdu': '80E2900000'}, + 'check': {'sw': {'mode': 'mask', 'value': '91??'}, 'por': 'none'}}, + {'type': 'expect', 'command': 'SEND SHORT MESSAGE', + 'checks': [{'kind': 'por', 'status': 'por_ok', 'sw': '9000'}], + 'respond': {'result': 'ok'}}, + ], preset) + self.assertEqual(run['status'], 'ok', run['steps']) + self.assertEqual(run['steps'][1]['por']['response_status'], 'por_ok') + labels = {c['label']: c for c in run['steps'][1]['checks']} + self.assertTrue(labels['PoR status']['ok'], labels) + self.assertTrue(labels['PoR SW']['ok'], labels) + args = dec.call_args[0] + self.assertEqual(args[4], '0000000A') # the command's counter + self.assertEqual(args[5], 'AA' * 16) # the keyset's keys + self.assertEqual(args[6], 'BB' * 16) + self.assertEqual(args[-1], '027100027100aabb') + + def test_expect_por_check_without_a_prior_scp80_fails(self): + scc = FakeScc() + scc.push('80F2', '', '9102') + scc.push('8012', self.SEND_SM_POR_CMD, '9000') + scc.push('8014', '', '9000') + run = self.run_script(FakeServer(scc), [ + {'type': 'action', 'kind': 'status', 'params': {'attempts': 2}, + 'check': {'sw': {'mode': 'mask', 'value': '91??'}}}, + {'type': 'expect', 'command': 'SEND SHORT MESSAGE', + 'checks': [{'kind': 'por', 'status': 'por_ok'}], + 'respond': {'result': 'ok'}}, + ]) + self.assertIsNone(run['steps'][1].get('por')) + por_check = [c for c in run['steps'][1]['checks'] if c['label'] == 'PoR'][0] + self.assertFalse(por_check['ok']) + self.assertIn('no PoR', por_check['actual']) + + def test_expect_checks_the_refresh_file_list(self): + scc = FakeScc() + scc.push('80F2', '', '9102') + scc.push('8012', self.REFRESH_FCN_CMD, '9000') + scc.push('8014', '', '9000') + run = self.run_script(FakeServer(scc), [ + {'type': 'action', 'kind': 'status', 'params': {'attempts': 2}, + 'check': {'sw': {'mode': 'mask', 'value': '91??'}}}, + {'type': 'expect', 'command': 'REFRESH', 'qualifier': '00', + 'checks': [{'kind': 'files', 'value': '3F007F106F3A'}], + 'respond': {'result': 'ok'}}, + ]) + self.assertEqual(run['status'], 'ok', run['steps']) + labels = {c['label']: c for c in run['steps'][1]['checks']} + self.assertTrue(labels['Command']['ok'], labels) + self.assertTrue(labels['Qualifier']['ok'], labels) + self.assertTrue(labels['Files']['ok'], labels) + self.assertEqual(labels['Files']['actual'], '3F007F106F3A') + + def test_parse_file_list_splits_concatenated_paths(self): + raw = bytes.fromhex('D016810301010082028381120B023F002FE23F007F106F3A') + self.assertEqual(S._parse_file_list(raw), ['3F002FE2', '3F007F106F3A']) + self.assertEqual(S._parse_file_list(bytes.fromhex('D009810301010082028381')), []) + def test_file_write_and_read_actions(self): class FakeLchan: selected_file = None