From e147ab90b48a49ed06cdce3438c07fe55bcd9db1 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: Fri, 2 Oct 2026 07:20:36 +0300 Subject: [PATCH] Code-review fixes: bad-format visibility, view-aware sync, counter hygiene - The RAM step line (and the plain send's PoR line) now name a bad-format failure ("bad format 02 (wrong length)") instead of reading "PoR ok": the step record carries por_bad_format_name and ramStepLine renders it. - ramSyncCounter follows the view the notice came from ('ram'/'sp' - preset and keyset selectors alike) and refreshes the keyset labels after a sync. - The ETSI-release line no longer hides behind the memory block (it needs only the ELF list). - /api/send-ota reports final_cntr only for a packet the card accepted: the failed-envelope path used to persist the packet's own counter on a rejection (the audit trail's small bump on cntr_low). - _ram_detect_format's docstring corrected (both expanded codings execute on the live ISD; the earlier bad format was a length-mismatched template). - Tests: ramStepLine bad format, the view-aware notice, the accepted/rejected final_cntr cases, and an end-to-end /api/counter-probe endpoint test (400s, no write on a rejected walk, accepted+1 stored). - sw cache simple-v356. --- frontend/index.html | 89 ++++++++++++++++++------- frontend/sw.js | 2 +- frontend/tests/ram.test.js | 28 ++++++-- frontend/tests/ram_counter_flow.test.js | 2 +- pysim_simple_server/server.py | 36 +++++++--- tests/test_fetch_routes.py | 67 +++++++++++++++++++ 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 3bd3996..9a5ecac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8335,6 +8335,8 @@ async function pysimSendOta() { if (por && por.response_status) { msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')'; if (por.cntr) msg += ' | PoR CNTR: ' + por.cntr; + if (por.bad_format) msg += ' | ' + t('bad format') + ' ' + por.bad_format + + (por.bad_format_name ? ' (' + por.bad_format_name + ')' : ''); if (por.decoded && por.decoded.last_status_word) msg += ' | last SW ' + por.decoded.last_status_word + ' ' + (por.decoded.last_response_data || ''); const okPor = por.response_status === 'por_ok'; porStatusEl.textContent = 'PoR: ' + por.response_status; @@ -9503,26 +9505,33 @@ function ramDeriveElfVersions(elfs, apps) { // Render the full explorer result as structured HTML function ramRenderExploreHtml(mem, isd, apps, elfs, fmt, expanded, elfForm) { let html = ''; + // The card's ETSI release needs only the ELF list: it shows inside the + // resources box when that exists, and on its own line when the memory + // query failed + const etsi = cardEtsiRelease(elfs); + let etsiHtml = ''; + if (etsi) { + let val; + if (etsi.release) val = etsi.release + (etsi.basis ? ' (' + etsi.basis + ')' : ''); + else if (etsi.basis) val = t('unknown') + ' (' + etsi.basis + ')'; + else val = t('unknown') + ' \u2014 ' + ramElfVersionHint(elfForm); + etsiHtml = '
' + + esc(t('ETSI release:')) + ' ' + esc(val) + '
'; + } if (mem && (mem.appCount != null || mem.freeNV != null || mem.freeV != null)) { html += '
'; html += '
' + esc(t('Card resources (GET DATA FF21)')) + '
'; html += '
' + esc(t('Applications:')) + ' ' + (mem.appCount != null ? mem.appCount : '?') + '
'; html += '
' + esc(t('Free NV:')) + ' ' + (mem.freeNV != null ? mem.freeNV + ' B' : '?') + '
'; html += '
' + esc(t('Free Volatile:')) + ' ' + (mem.freeV != null ? mem.freeV + ' B' : '?') + '
'; - const etsi = cardEtsiRelease(elfs); - if (etsi) { - let val; - if (etsi.release) val = etsi.release + (etsi.basis ? ' (' + etsi.basis + ')' : ''); - else if (etsi.basis) val = t('unknown') + ' (' + etsi.basis + ')'; - else val = t('unknown') + ' \u2014 ' + ramElfVersionHint(elfForm); - html += '
' + - esc(t('ETSI release:')) + ' ' + esc(val) + '
'; - } + html += etsiHtml; if (fmt) { html += '
' + esc(t('RAM command format:')) + ' ' + esc(fmt) + '
'; } html += '
'; + } else if (etsiHtml) { + html += '
' + etsiHtml + '
'; } if (expanded) { html += '
' + esc(expanded) + '
'; @@ -10173,7 +10182,13 @@ function ramStepLine(s, idx, comps, blockSize) { if (s.bytes) line += ' \u00b7 ' + s.bytes + ' B'; if (s.segments > 1) line += ' / ' + s.segments + ' SMS'; } else { - if (s.por_status === 'por_ok') line += t('PoR ok'); + if (s.por_bad_format) { + // the card parsed the scripting template but aborted on a + // malformed command (TS 102 226 5.2.2 Bad format TLV): there is no + // remote status word to show + line += t('bad format') + ' ' + s.por_bad_format + + (s.por_bad_format_name ? ' (' + s.por_bad_format_name + ')' : ''); + } else if (s.por_status === 'por_ok') line += t('PoR ok'); else if (s.por_status === 'no_por') line += t('no PoR'); else if (s.por_status && s.por_status.indexOf('por_error') === 0) { const legacy = s.por_status.replace(/^por_error_?/, ''); @@ -10519,8 +10534,9 @@ async function ramCompatTest(sp) { if (probeNote) modal += '
' + esc(probeNote) + '
'; modal += ramCompatDetailHtml(data, mem); if (spCntrLow(data)) { - modal += ramCntrLowHtml(ramCntrLowPresetIdx()); - ramShowCntrLow(); + const v = ramCntrLowView(); + modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v); + ramShowCntrLow(undefined, v); } ramOpModalFinish(verdict, !!data.imports_ok, modal, true); } @@ -10880,27 +10896,42 @@ function ramNvLine(data) { // The preset the failed operation used (the RAM form's selection, else the // SCP80 form's) - the "Go to preset" target. -function ramCntrLowPresetIdx() { +// The view a low-counter notice came from ('ram' | 'sp'): the notice's +// actions (sync probe, preset jump) must follow the failed operation's view - +// the two views can sit on different presets. +function ramCntrLowView() { + const pick = id => { + const el = document.getElementById(id); + const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN; + return (!isNaN(i) && cards[i]) ? i : -1; + }; + return pick('ram-card-sel') >= 0 ? 'ram' : 'sp'; +} + +function ramCntrLowPresetIdx(view) { const pick = id => { const el = document.getElementById(id); const i = el && el.value !== '' ? parseInt(el.value, 10) : NaN; return (!isNaN(i) && cards[i]) ? i : -1; }; const ram = pick('ram-card-sel'); - return ram >= 0 ? ram : pick('sp-card-sel'); + const sp = pick('sp-card-sel'); + if (view === 'sp') return sp >= 0 ? sp : ram; + return ram >= 0 ? ram : sp; } // Low-counter notice (pure HTML): the facts plus two actions - a bounded // counter sync probe and a button that opens the failed operation's preset in // the Cards tab, where the counter can be raised by hand. -function ramCntrLowHtml(presetIdx) { +function ramCntrLowHtml(presetIdx, view) { const idx = Number.isInteger(presetIdx) && presetIdx >= 0 ? presetIdx : -1; + const v = view === 'sp' ? 'sp' : 'ram'; const text = t('Low counter') + ' \u2014 ' + t('the card rejected the packet'); const btnCls = 'px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 ' + 'hover:bg-red-100 dark:hover:bg-red-900/40 whitespace-nowrap'; return '
' + '' + text + '' + - '' + '
'; @@ -10908,9 +10939,10 @@ function ramCntrLowHtml(presetIdx) { // Show the low-counter notice in the RAM result area (and, when the operation // modal is still open, in its detail block). -function ramShowCntrLow(presetIdx) { - const idx = presetIdx === undefined ? ramCntrLowPresetIdx() : presetIdx; - const html = ramCntrLowHtml(idx); +function ramShowCntrLow(presetIdx, view) { + const v = view === undefined ? ramCntrLowView() : view; + const idx = presetIdx === undefined ? ramCntrLowPresetIdx(v) : presetIdx; + const html = ramCntrLowHtml(idx, v); const el = document.getElementById('ram-cntr-low'); if (el && el.classList) { el.innerHTML = html; @@ -10927,21 +10959,24 @@ function ramShowCntrLow(presetIdx) { // +4, ...) until the card accepts one, stops at a 32-bit ceiling and persists // accepted+1. A rejected attempt changes nothing on the card, so only the // accepted packet moves its counter (the card never reports its value). -async function ramSyncCounter(presetIdx) { +async function ramSyncCounter(presetIdx, view) { const i = Number(presetIdx); const preset = (Number.isInteger(i) && i >= 0 && cards[i]) ? cards[i] : null; const box = document.getElementById('ram-cntr-low'); if (!preset) { alert(t('No card preset selected')); return; } - // The keyset the failed operation used: the RAM form's selector, else the - // SCP80 form's (both hold the keyset number as their option value). + // The keyset the failed operation used: the originating view's selector + // first, the other view's as the fallback (both hold the keyset number as + // their option value). const kvnOf = id => { const sel = document.getElementById(id); const v = sel && sel.value !== '' ? parseInt(sel.value, 10) : NaN; return Number.isInteger(v) && v > 0 ? v : null; }; + const own = view === 'sp' ? 'sp-keyset-sel' : 'ram-keyset-sel'; + const other = own === 'ram-keyset-sel' ? 'sp-keyset-sel' : 'ram-keyset-sel'; const val = id => (document.getElementById(id) || {}).value || ''; const body = { preset_id: preset.id }; - const kvn = kvnOf('ram-keyset-sel') || kvnOf('sp-keyset-sel'); + const kvn = kvnOf(own) || kvnOf(other); if (kvn) body.kvn = kvn; const tar = val('sp-tar').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); if (tar) body.tar = tar; @@ -10955,6 +10990,8 @@ async function ramSyncCounter(presetIdx) { const data = await pysimFetch('/api/counter-probe', body); if (data.success) { await cardsFetch(); + // the keyset selects' labels carry the counter - refresh them too + spKeysetSync(); const cntrEl = document.getElementById('sp-cntr'); if (cntrEl && data.stored_cntr) cntrEl.value = data.stored_cntr; const msg = t('Counter synced') + ': ' + esc(data.accepted_cntr) + ' \u2192 ' + @@ -11037,8 +11074,9 @@ function ramShowInstallResult(data, okText, capMem) { // low counter: not recoverable by retrying - state it and offer the // bounded sync probe or the preset jump (v3.6.58) if (spCntrLow(data)) { - modal += ramCntrLowHtml(ramCntrLowPresetIdx()); - ramShowCntrLow(); + const v = ramCntrLowView(); + modal += ramCntrLowHtml(ramCntrLowPresetIdx(v), v); + ramShowCntrLow(undefined, v); } ramOpModalFinish(resultEl.textContent, !!data.success, modal, true); } @@ -19644,6 +19682,7 @@ const LANG_RU = { 'Free NV:': 'Свободно NV:', 'ETSI release:': 'Релиз ETSI:', 'unknown': 'неизвестно', + 'bad format': 'неверный формат', 'Inferred from the card\'s installed uicc.toolkit package version (the same table as the CAP analysis); the expanded registry query provides the versions.': 'Определяется по версии установленного на карте пакета uicc.toolkit (та же таблица, что и в анализе CAP); версии даёт расширенный запрос реестра.', 'Free Volatile:': 'Свободно volatile:', 'AID:': 'AID:', diff --git a/frontend/sw.js b/frontend/sw.js index 01acac4..a2b39ad 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v355'; +const CACHE = 'simple-v356'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index fc91421..478691d 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -27,7 +27,7 @@ const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamB 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu', '_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries', 'ramExpandedReport', 'ramExpandedMore', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'cardEtsiRelease', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate', - 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', + 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetOptionsHtml', 'spPresetIdx', 'tarPresetIdx', 'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged', @@ -852,18 +852,21 @@ test('ramCntrLowHtml states the facts without a fabricated counter', () => { globalThis.t = s => s; const out = ramCntrLowHtml(2); const noPreset = ramCntrLowHtml(-1); + // the notice follows the failed operation's view ('sp' -> its own keyset) + const spView = ramCntrLowHtml(1, 'sp'); delete globalThis.t; assert.ok(out.includes('Low counter \u2014 the card rejected the packet'), out); // the PoR's CNTR is a copy of the command's counter, never the card's: no // value may be shown (v3.9.x), only the verdict and the two actions assert.ok(!out.includes('card counter'), out); - assert.ok(out.includes('onclick="ramSyncCounter(2)"'), out); + assert.ok(out.includes('onclick="ramSyncCounter(2, \'ram\')"'), out); assert.ok(out.includes('Sync counter'), out); assert.ok(out.includes('onclick="ramGoToPreset(2)"'), out); assert.ok(out.includes('Go to preset'), out); // without a known preset the buttons still work (-1) assert.ok(noPreset.includes('onclick="ramGoToPreset(-1)"'), noPreset); - assert.ok(noPreset.includes('onclick="ramSyncCounter(-1)"'), noPreset); + assert.ok(noPreset.includes('onclick="ramSyncCounter(-1, \'ram\')"'), noPreset); + assert.ok(spView.includes('onclick="ramSyncCounter(1, \'sp\')"'), spView); }); test('the explorer shows versions on app and ISD rows too', () => { @@ -877,6 +880,19 @@ test('the explorer shows versions on app and ISD rows too', () => { assert.ok(out.includes('Version: 9.3'), out); }); +test('ramStepLine names a bad-format failure instead of "PoR ok"', () => { + globalThis.t = s => s; + const line = ramStepLine({ name: 'P2=02 (lean)', por_status: 'por_ok', + por_error: 'bad format 02 (wrong length)', por_bad_format: '02', + por_bad_format_name: 'wrong length' }, 0); + const plain = ramStepLine({ name: 'LOAD', por_status: 'por_ok', por_sw: '9000' }, 1); + delete globalThis.t; + assert.ok(line.includes('bad format 02 (wrong length)'), line); + assert.ok(!line.includes('PoR ok'), line); + // a healthy step is unchanged + assert.ok(plain.includes('9000') || plain.includes('PoR ok'), plain); +}); + test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () => { const src = extractFunc(html, 'ramExplore'); assert.ok(src.includes('elfTagListRetry'), 'the P1=20 tag-list retry must exist'); @@ -908,9 +924,11 @@ test('every SCP80/RAM flow checks the low counter and stops', () => { // preset, the keyset the failed operation used and its SPI/TAR const sync = extractFunc(html, 'ramSyncCounter'); assert.ok(sync.includes("'/api/counter-probe'"), sync); - assert.ok(sync.includes("kvnOf('ram-keyset-sel')"), sync); - assert.ok(sync.includes("kvnOf('sp-keyset-sel')"), sync); + assert.ok(sync.includes("own = view === 'sp'"), sync); + assert.ok(sync.includes('kvnOf(own)'), sync); + assert.ok(sync.includes('kvnOf(other)'), sync); assert.ok(sync.includes("getElementById('sp-cntr')"), sync); + assert.ok(sync.includes('spKeysetSync()'), 'the keyset labels must refresh'); }); test('the RAM form offers the command format choice', () => { diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js index 3685a85..42935ac 100644 --- a/frontend/tests/ram_counter_flow.test.js +++ b/frontend/tests/ram_counter_flow.test.js @@ -30,7 +30,7 @@ let code = ''; for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr', 'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress', 'ramHideProgress', 'ramDeleteFromExplorer', - 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow', + 'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow', 'escHtml', 'esc', 'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck', 'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender', diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index b6c46a6..b84e8aa 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -1553,10 +1553,11 @@ def _ram_detect_format(server, scc, sp, state): """Detect the card's RAM command format for one operation: send the read-only GET STATUS [ISD] probe compact, then in the two expanded codings; the first format whose remote SW succeeds wins (compact - preferred). Cards implement different subsets - the live ISD answers the - definite form with a Bad format TLV (wrong length) and executes the - indefinite one, while an RFM applet took the definite form. Probe - packets are recorded as steps and consume counters only when accepted; + preferred). Cards implement different subsets: the reference TCA loader + sends the listing queries wrapped and unchained, while the live card needs + the compact chain (a length-mismatched template draws a Bad format TLV, + which the step result names). Probe packets are recorded as steps and + consume counters only when accepted; the result is used for this operation only - the next operation re-checks (v3.6.24).""" for fmt, apdu in (('compact', _RAM_PROBE_APDU), @@ -1579,7 +1580,8 @@ def _ram_detect_format(server, scc, sp, state): # accepted, so rejected attempts change nothing; the search walks a doubling # ladder (start+1, +2, +4, ...) and stops at a ceiling far below the 40-bit # maximum, where the counter would get blocked. -COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF: read-only, applet-visible +COUNTER_PROBE_DEFAULT_APDU = '00A40000023F00' # SELECT MF, as TAR_PROBE_DEFAULT_APDU + # (a literal: that constant is defined later) COUNTER_PROBE_CEILING = 'FFFFFFFF' # 32-bit default COUNTER_PROBE_MAX_ATTEMPTS = 40 # doubling covers 40 bits @@ -1810,6 +1812,7 @@ def _ram_step_result(step_name, last_sw, por, por_hex, bytes_, segments): # the expanded script was rejected as malformed before executing: # there is no remote status word step['por_bad_format'] = por['bad_format'] + step['por_bad_format_name'] = por.get('bad_format_name') error = 'bad format %s (%s)' % (por['bad_format'], por.get('bad_format_name') or '') elif pstatus != 'por_ok': @@ -3793,7 +3796,8 @@ def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, dt=None): The object order follows TS 102 223 6.8.0: command details, device identities, Result, then the command-specific data objects (the echoed - Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION).""" + Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION). + `dt` is a test seam for the PLI date/time default (see _pli_data_hex).""" base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00, 0x82, 0x02, dev_dst, dev_src, 0x03, 0x01, 0x00]) @@ -7275,8 +7279,6 @@ class PysimHandler(BaseHTTPRequestHandler): resp = result if ram_format is not None: resp['ram_format'] = ram_format - if _counter_valid(cntr) and counter_tracked: - resp['final_cntr'] = cntr # A warning ENVELOPE may still carry the PoR (the # card's counter verdict): decode it so the caller # sees 'cntr_low' instead of a bare failure. @@ -7294,6 +7296,15 @@ class PysimHandler(BaseHTTPRequestHandler): resp.update(low) sys.stderr.write('OTA CNTR-LOW: the packet counter is not ' 'above the card\'s\n') + # only a packet the card accepted consumes the + # counter (a warning ENVELOPE can still carry a + # por_ok PoR); a rejected one must leave the store + # untouched (v3.9.x - it used to persist the + # packet's own counter here) + if (_counter_valid(cntr) and counter_tracked and + failed_por.get('response_status') in + ('por_ok', 'actual_response_sms_submit')): + resp['final_cntr'] = _ram_next_cntr(cntr, True) sys.stderr.write('OTA SEND FAILED: %s\n' % result.get('error')) else: resp = {'success': True, 'sw': result['sw'], @@ -7358,9 +7369,12 @@ class PysimHandler(BaseHTTPRequestHandler): # (v3.8.0). A pre-built packet may come without a # counter, and a packet whose SPI1 does not ask the card # to check it has none to track - nothing is reported - # then (and int('') never happens). - if _counter_valid(cntr) and counter_tracked: - resp['final_cntr'] = _ram_next_cntr(cntr, accepted) + # then (and int('') never happens). A rejected verdict + # (cntr_low, PoR error) reports nothing either: the + # store must not move on a packet the card did not + # consume. + if _counter_valid(cntr) and counter_tracked and accepted: + resp['final_cntr'] = _ram_next_cntr(cntr, True) finally: if submit_handler and hasattr(scc, '_tp'): scc._tp.proactive_handler = old_proactive diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 5eeb956..2a93ed0 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -393,6 +393,31 @@ class CntrLowGuardHttpTests(unittest.TestCase): # the verdict is reported without a value (v3.9.x) self.assertNotIn('card_cntr', resp) self.assertNotIn('suggested_cntr', resp) + # a rejected packet reports no counter to use next either (v3.9.x): + # the store must not move on a packet the card did not consume + self.assertNotIn('final_cntr', resp) + + def test_send_ota_reports_the_next_counter_only_when_accepted(self): + patches = [ + mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: { + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000b0a00000000000000bb0000'}), + mock.patch.object(self.srv, '_decode_por', lambda *a, **k: { + 'response_status': 'por_ok', 'tar': '000000', 'cntr': '00000000BB', + 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}), + ] + self.server.app = object() # the send path only checks truthiness + for p in patches: + p.start() + try: + status, resp = self._post('/api/send-ota', { + 'sp': '00', 'spi1': '16', 'spi2': '01', 'kic': '15', 'kid': '15', + 'tar': '000000', 'cntr': '00000000BB'}) + finally: + for p in reversed(patches): + p.stop() + self.assertEqual(status, 200, resp) + self.assertEqual(resp['final_cntr'], '00000000BC') def test_the_ram_chain_stops_at_a_low_counter(self): calls = [] @@ -517,6 +542,48 @@ class PresetStoreHttpTests(unittest.TestCase): self.assertEqual(resp['presets'], []) self.assertEqual(resp['path'], str(self.store.path)) + def test_counter_probe_endpoint(self): + status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) + self.assertEqual(status, 200, resp) + pid = resp['preset']['id'] + status, resp = self._post('/api/counter-probe', {}) + self.assertEqual(status, 400, resp) + self.assertIn('preset_id', resp['error']) + status, resp = self._post('/api/counter-probe', {'preset_id': 'nope'}) + self.assertEqual(status, 400, resp) + self.assertIn('unknown preset', resp['error']) + # a walk that never gets accepted leaves the store untouched + with mock.patch.object(self.srv, '_build_secured_packet', + lambda *a, **k: ('00' * 20, {})), \ + mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: { + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000b0a00000000000000000000'}), \ + mock.patch.object(self.srv, '_decode_por', lambda *a, **k: { + 'response_status': 'cntr_low', 'tar': '000000', 'cntr': '0000000000', + 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}): + status, resp = self._post('/api/counter-probe', + {'preset_id': pid, 'max_attempts': 2}) + self.assertEqual(status, 200, resp) + self.assertFalse(resp['success'], resp) + self.assertEqual(resp['packets'], 2) + self.assertEqual(resp['stopped'], 'attempts') + self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000001') + # an accepted walk stores accepted+1 (the first ladder value) + with mock.patch.object(self.srv, '_build_secured_packet', + lambda *a, **k: ('00' * 20, {})), \ + mock.patch.object(self.srv, '_send_secured_packet', lambda *a, **k: { + 'success': True, 'sw': '9000', 'bytes': 34, 'segments': 1, + 'response_data': '027100000b0a00000000000000000000'}), \ + mock.patch.object(self.srv, '_decode_por', lambda *a, **k: { + 'response_status': 'por_ok', 'tar': '000000', 'cntr': '0000000002', + 'pcntr': 0, 'rpl': 11, 'rhl': 10, 'raw': ''}): + status, resp = self._post('/api/counter-probe', {'preset_id': pid}) + self.assertEqual(status, 200, resp) + self.assertTrue(resp['success'], resp) + self.assertEqual(resp['accepted_cntr'], '0000000002') + self.assertEqual(resp['stored_cntr'], '0000000003') + self.assertEqual(self.store.get(pid)['keysets'][0]['cntr'], '0000000003') + def test_create_update_delete_round_trip(self): status, resp = self._post('/api/presets', self._preset(iccid='8970119000004600098')) self.assertEqual(status, 200, resp)