feat: answer GET INKEY / GET INPUT interactively (v3.6.38)
The menu chain paused only for DISPLAY TEXT / SELECT ITEM / SET UP MENU, so a GET INPUT was auto-answered with a bare success TR and no Text string - the live RemMobileID PIN prompt got an empty answer. Server: _parse_get_inkey/_parse_get_input read the prompt, qualifier flags (digits-only, UCS2, Yes/No, immediate, hidden, packed, help), Response length 91 and Default Text 9D; the fetch handler pauses with them; _menu_send_response(..., text=) validates the answer (digits set, min/max, one char for GET INKEY) and builds the Text string TLV 8D in the requested coding (SMS unpacked 04 / UCS2 08 / packed 00 via _pack_gsm7), Yes/No as 01/00, empty input as the null text string, help as result 13 and the variable-timeout Duration echoed (6.8.4). /api/menu-respond takes text; /api/stk-status carries pending_data so a card-initiated prompt opens the panel; _decode_cmd decodes both commands. PWA: stkInputHtml/stkInputHint/stkInputValidate render the constrained panel (password field for hidden entry, numeric keyboard, default text, Yes/No buttons, help, immediate-digit auto-submit); OK submits through stkMenuOk; the poll opens the panel for card-initiated prompts. sw cache simple-v311; tests: 517 Python / 674 Node.
This commit is contained in:
@@ -488,7 +488,7 @@
|
||||
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Четыре подвкладки: <strong>«Телефон»</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), <strong>«Конфигурация TR»</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд), <strong>«eSIM LPA»</strong> (локальные операции с eUICC) и <strong>«Тест-скрипт»</strong> (сценарные тест-скрипты карты с ожиданием проактивных команд, см. <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">§8.9</a>).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 Меню STK</h3>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала <strong>Next Action Indicator</strong> (TS 102 223 §8.24), показывают его серым суффиксом (например, <code class="font-mono text-sm">→ SET UP MENU</code>) — что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
|
||||
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой <strong>STK: <название></strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала <strong>Next Action Indicator</strong> (TS 102 223 §8.24), показывают его серым суффиксом (например, <code class="font-mono text-sm">→ SET UP MENU</code>) — что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять. Оверлей также отвечает на запросы карты <strong>GET INKEY</strong> и <strong>GET INPUT</strong> (TS 102 223 §6.4.2/§6.4.3): панель показывает текст карты и поле ввода, ограничения которого заданы командой (длина ответа min–max, цифровая клавиатура, скрытый ввод, подставленный текст по умолчанию, кнопки Да/Нет для GET INKEY, справка, если карта её объявила), а ответ возвращается в Text string внутри TERMINAL RESPONSE в запрошенной кодировке (SMS-алфавит unpacked, UCS2 или packed).</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Подписанные события (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
|
||||
|
||||
+1
-1
@@ -487,7 +487,7 @@
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has four pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log), <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands), <strong>eSIM LPA</strong> (local eUICC operations) and <strong>Test script</strong> (scripted card test scripts with proactive-command expectations, see <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">§8.9</a>).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 STK menu</h3>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 §8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">→ SET UP MENU</code>) — what the card would do when the item is selected. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 §8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">→ SET UP MENU</code>) — what the card would do when the item is selected. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute. The overlay also answers the card’s <strong>GET INKEY</strong> and <strong>GET INPUT</strong> prompts (TS 102 223 §6.4.2/§6.4.3): the panel shows the card’s text with an input field whose constraints come from the command (Response length min–max, digits-only keyboard, hidden entry, default text prefilled, Yes/No buttons for GET INKEY, help when the card advertises it), and the answer is returned in the TERMINAL RESPONSE’s Text string in the requested coding (SMS default alphabet unpacked, UCS2 or packed).</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Subscribed events (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
|
||||
+155
-2
@@ -1705,7 +1705,7 @@
|
||||
<div id="stk-menu-buttons" class="flex justify-between mt-3 pt-3 border-t border-gray-200 dark:border-slate-700 hidden">
|
||||
<button id="stk-timeout-btn" onclick="stkMenuRespond('timeout')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Timeout">Timeout</button>
|
||||
<button id="stk-cancel-btn" onclick="stkMenuRespond('cancel')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Cancel">Cancel</button>
|
||||
<button id="stk-ok-btn" onclick="stkMenuRespond('ok')" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
|
||||
<button id="stk-ok-btn" onclick="stkMenuOk()" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1732,7 +1732,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.6.37';
|
||||
const SIMPLE_VERSION = '3.6.38';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
@@ -9688,10 +9688,126 @@ async function stkMenuItemClick(itemId) {
|
||||
}
|
||||
}
|
||||
|
||||
// User-input prompt for a pending GET INKEY / GET INPUT (pure). The card's
|
||||
// constraints drive the field: hidden entry masks the value (TS 102 223
|
||||
// 6.4.3), digits-only switches the keyboard, Response length bounds the text.
|
||||
function stkInputHtml(pd) {
|
||||
pd = pd || {};
|
||||
let html = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded mb-2">' + esc(pd.text || '') + '</div>';
|
||||
if (pd.yes_no) {
|
||||
html += '<div class="flex gap-2 justify-center">' +
|
||||
'<button onclick="stkInputSubmit(\'01\')" class="px-4 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Yes')) + '</button>' +
|
||||
'<button onclick="stkInputSubmit(\'00\')" class="px-4 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600">' + esc(t('No')) + '</button>' +
|
||||
'</div>';
|
||||
return html;
|
||||
}
|
||||
const max = Number(pd.max) || 0;
|
||||
const attrs = [
|
||||
'id="stk-input-field"',
|
||||
'class="w-full px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 font-mono"',
|
||||
'type="' + (pd.hidden ? 'password' : 'text') + '"',
|
||||
pd.digits_only ? 'inputmode="numeric"' : '',
|
||||
(max && max !== 0xFF) ? 'maxlength="' + max + '"' : '',
|
||||
pd.immediate ? 'oninput="stkInputImmediate()"' : '',
|
||||
'value="' + esc(pd.default || '') + '"',
|
||||
].filter(Boolean).join(' ');
|
||||
html += '<input ' + attrs + '>';
|
||||
const hint = stkInputHint(pd);
|
||||
if (hint) html += '<div class="text-xs text-gray-400 dark:text-slate-500 mt-1">' + esc(hint) + '</div>';
|
||||
if (pd.help) {
|
||||
html += '<div class="mt-2 text-center"><button onclick="stkMenuRespond(\'help\')" class="text-xs underline text-gray-500 dark:text-slate-400">' + esc(t('Help')) + '</button></div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// '4–16 characters · digits 0-9 * # + · hidden entry' (pure).
|
||||
function stkInputHint(pd) {
|
||||
pd = pd || {};
|
||||
const parts = [];
|
||||
const lo = Number(pd.min) || 0;
|
||||
const hi = Number(pd.max) || 0;
|
||||
const hasHi = hi && hi !== 0xFF;
|
||||
if (pd.type === 'get_inkey') {
|
||||
parts.push(t('one character'));
|
||||
} else if (lo && hasHi) {
|
||||
parts.push(lo + '\u2013' + hi + ' ' + t('characters'));
|
||||
} else if (hasHi) {
|
||||
parts.push(t('up to') + ' ' + hi + ' ' + t('characters'));
|
||||
} else if (lo) {
|
||||
parts.push(t('At least') + ' ' + lo + ' ' + t('characters'));
|
||||
}
|
||||
if (pd.digits_only || pd.hidden) parts.push(t('digits 0-9 * # +'));
|
||||
if (pd.hidden) parts.push(t('hidden entry'));
|
||||
return parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
// Validate the typed answer against the card's request (pure; mirrors the
|
||||
// server rules so the panel can explain before sending).
|
||||
function stkInputValidate(pd, value) {
|
||||
pd = pd || {};
|
||||
const v = value == null ? '' : String(value);
|
||||
const digits = /^[0-9*#+]*$/;
|
||||
if (pd.type === 'get_inkey') {
|
||||
if (v.length !== 1) return { ok: false, error: t('Enter exactly one character') };
|
||||
if (pd.digits_only && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
|
||||
return { ok: true };
|
||||
}
|
||||
const lo = Number(pd.min) || 0;
|
||||
const hi = Number(pd.max) || 0;
|
||||
if (v.length < lo) return { ok: false, error: t('At least') + ' ' + lo + ' ' + t('characters') };
|
||||
if (hi && hi !== 0xFF && v.length > hi) return { ok: false, error: t('At most') + ' ' + hi + ' ' + t('characters') };
|
||||
if ((pd.digits_only || pd.hidden) && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
let _stkInputPending = null;
|
||||
|
||||
// Send the typed (or Yes/No) answer; the server validates again and builds
|
||||
// the Text string TERMINAL RESPONSE.
|
||||
async function stkInputSubmit(text) {
|
||||
const pd = _stkInputPending || {};
|
||||
if (text !== '01' && text !== '00') {
|
||||
const field = document.getElementById('stk-input-field');
|
||||
const value = field ? field.value : '';
|
||||
const v = stkInputValidate(pd, value);
|
||||
if (!v.ok) {
|
||||
document.getElementById('stk-menu-panel-content').insertAdjacentHTML('beforeend',
|
||||
'<div class="text-xs text-red-500 mt-1">' + esc(v.error) + '</div>');
|
||||
return;
|
||||
}
|
||||
text = value;
|
||||
}
|
||||
const content = document.getElementById('stk-menu-panel-content');
|
||||
content.innerHTML = '<span class="text-gray-500 text-sm">' + esc(t('Sending...')) + '</span>';
|
||||
try {
|
||||
const data = await pysimFetch('/api/menu-respond', { result: 'ok', text: text });
|
||||
stkMenuHandleResponse(data);
|
||||
} catch (e) {
|
||||
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// The OK button: submit the pending input, otherwise the plain 'ok' answer.
|
||||
function stkMenuOk() {
|
||||
if (_stkInputPending && (_stkInputPending.type === 'get_input' || _stkInputPending.type === 'get_inkey')) {
|
||||
stkInputSubmit(null);
|
||||
return;
|
||||
}
|
||||
stkMenuRespond('ok');
|
||||
}
|
||||
|
||||
// Immediate digit response (GET INKEY qualifier bit 4): pass the first
|
||||
// character on without asking for confirmation.
|
||||
function stkInputImmediate() {
|
||||
const field = document.getElementById('stk-input-field');
|
||||
if (field && field.value.length >= 1) stkInputSubmit(null);
|
||||
}
|
||||
|
||||
function stkMenuHandleResponse(data) {
|
||||
const content = document.getElementById('stk-menu-panel-content');
|
||||
const backBtn = document.getElementById('stk-back-btn');
|
||||
const btns = document.getElementById('stk-menu-buttons');
|
||||
_stkInputPending = null;
|
||||
btns.classList.add('hidden');
|
||||
if (data.type === 'display_text') {
|
||||
content.innerHTML = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded">' + esc(data.text) + '</div>';
|
||||
@@ -9704,6 +9820,12 @@ function stkMenuHandleResponse(data) {
|
||||
backBtn.style.display = '';
|
||||
document.getElementById('stk-ok-btn').style.display = 'none';
|
||||
btns.classList.remove('hidden');
|
||||
} else if (data.type === 'get_input' || data.type === 'get_inkey') {
|
||||
_stkInputPending = data;
|
||||
content.innerHTML = stkInputHtml(data);
|
||||
backBtn.style.display = '';
|
||||
document.getElementById('stk-ok-btn').style.display = data.yes_no ? 'none' : '';
|
||||
btns.classList.remove('hidden');
|
||||
} else {
|
||||
content.innerHTML = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
|
||||
stkSetPillsDisabled(false);
|
||||
@@ -9726,6 +9848,7 @@ async function stkSubItemClick(itemId) {
|
||||
// itself (the menu watchdog's timeout TERMINAL RESPONSE, or a new selection
|
||||
// that shadowed it): the dialog can no longer be answered (v3.6.35).
|
||||
function stkMenuPanelReset() {
|
||||
_stkInputPending = null;
|
||||
const panel = document.getElementById('stk-menu-panel');
|
||||
if (panel) panel.classList.add('hidden');
|
||||
const btns = document.getElementById('stk-menu-buttons');
|
||||
@@ -9734,6 +9857,18 @@ function stkMenuPanelReset() {
|
||||
if (back) back.style.display = 'none';
|
||||
}
|
||||
|
||||
// Open the panel for a pending command the server paused on by itself (a
|
||||
// card-initiated GET INPUT / SELECT ITEM): the reply to our own click renders
|
||||
// through stkMenuHandleResponse instead.
|
||||
function stkMenuShowPending(pd) {
|
||||
document.getElementById('stk-menu-panel-title').textContent = document.getElementById('stk-menu-title').textContent;
|
||||
stkSetPillsDisabled(true);
|
||||
stkMenuStack = [];
|
||||
document.getElementById('stk-back-btn').style.display = 'none';
|
||||
document.getElementById('stk-menu-panel').classList.remove('hidden');
|
||||
stkMenuHandleResponse(pd);
|
||||
}
|
||||
|
||||
// The /api/menu-respond reply for a command the server already answered.
|
||||
function stkNoPendingError(data) {
|
||||
return !!(data && typeof data.error === 'string'
|
||||
@@ -11921,6 +12056,12 @@ function pysimStartBackendPoll() {
|
||||
// A command the server answered by itself must not leave a stale
|
||||
// dialog behind (the panel is not refreshed when the tab is hidden)
|
||||
if (stkChanged && !stk.pending) stkMenuPanelReset();
|
||||
// A command the card sent on its own (not the reply to our click)
|
||||
// opens the panel with its prompt (v3.6.38)
|
||||
if (stkChanged && stk.pending && stk.pending_data && isViewVisible('tab-phone')
|
||||
&& document.getElementById('stk-menu-panel').classList.contains('hidden')) {
|
||||
stkMenuShowPending(stk.pending_data);
|
||||
}
|
||||
if (ts) {
|
||||
const wasRunning = testRunActive();
|
||||
_testRunState = ts;
|
||||
@@ -17497,6 +17638,18 @@ const LANG_RU = {
|
||||
'Event sent.': 'Событие отправлено.',
|
||||
'Event data sent.': 'Данные события отправлены.',
|
||||
'Sending...': 'Отправка...',
|
||||
'Yes': 'Да',
|
||||
'No': 'Нет',
|
||||
'Help': 'Справка',
|
||||
'one character': 'один символ',
|
||||
'characters': 'символов',
|
||||
'up to': 'до',
|
||||
'At least': 'Не менее',
|
||||
'At most': 'Не более',
|
||||
'digits 0-9 * # +': 'цифры 0-9 * # +',
|
||||
'hidden entry': 'скрытый ввод',
|
||||
'Enter exactly one character': 'Введите ровно один символ',
|
||||
'Digits only (0-9, *, #, +)': 'Только цифры (0-9, *, #, +)',
|
||||
'Invalid': 'Некорректное значение',
|
||||
'Service Status': 'Статус сервиса',
|
||||
'Normal service': 'Обычный сервис',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v310';
|
||||
const CACHE = 'simple-v311';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -26,6 +26,9 @@ code += extractFunc(html, 'stkNoPendingError') + '\n';
|
||||
code += extractFunc(html, 'stkMenuPanelReset') + '\n';
|
||||
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
|
||||
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
|
||||
code += extractFunc(html, 'stkInputHtml') + '\n';
|
||||
code += extractFunc(html, 'stkInputHint') + '\n';
|
||||
code += extractFunc(html, 'stkInputValidate') + '\n';
|
||||
code += 'globalThis.esc = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
@@ -132,3 +135,49 @@ test('an already-answered respond does not reach the dialog handler', async () =
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('stkInputHtml builds the card-driven input field', () => {
|
||||
globalThis.t = s => s;
|
||||
const out = stkInputHtml({ type: 'get_input', text: 'PIN', min: 4, max: 16,
|
||||
digits_only: true, hidden: true });
|
||||
assert.ok(out.includes('PIN'), out);
|
||||
assert.ok(out.includes('type="password"'), out); // hidden entry
|
||||
assert.ok(out.includes('inputmode="numeric"'), out); // digits only
|
||||
assert.ok(out.includes('maxlength="16"'), out);
|
||||
assert.ok(out.includes('4\u201316 characters'), out);
|
||||
assert.ok(out.includes('hidden entry'), out);
|
||||
// UCS2 alphabet entry: a plain text field without the numeric keyboard
|
||||
const ucs2 = stkInputHtml({ type: 'get_input', text: 'Name', min: 0, max: 10, ucs2: true });
|
||||
assert.ok(ucs2.includes('type="text"'), ucs2);
|
||||
assert.ok(!ucs2.includes('inputmode'), ucs2);
|
||||
assert.ok(ucs2.includes('up to 10 characters'), ucs2);
|
||||
// 0xFF = no maximum (TS 102 223 8.11): no maxlength and no range hint
|
||||
const noMax = stkInputHtml({ type: 'get_input', text: 'x', min: 0, max: 255 });
|
||||
assert.ok(!noMax.includes('maxlength'), noMax);
|
||||
assert.ok(!noMax.includes('characters'), noMax);
|
||||
// Yes/No: buttons instead of a field
|
||||
const yesNo = stkInputHtml({ type: 'get_inkey', text: 'Continue?', yes_no: true });
|
||||
assert.ok(yesNo.includes("stkInputSubmit('01')"), yesNo);
|
||||
assert.ok(yesNo.includes("stkInputSubmit('00')"), yesNo);
|
||||
assert.ok(!yesNo.includes('stk-input-field'), yesNo);
|
||||
// the card's default text is prefilled; help adds the help action
|
||||
const def = stkInputHtml({ type: 'get_input', text: 'x', default: '1234', help: true });
|
||||
assert.ok(def.includes('value="1234"'), def);
|
||||
assert.ok(def.includes("stkMenuRespond('help')"), def);
|
||||
});
|
||||
|
||||
test('stkInputValidate mirrors the server input rules', () => {
|
||||
globalThis.t = s => s;
|
||||
const pd = { type: 'get_input', min: 4, max: 16, digits_only: true, hidden: true };
|
||||
assert.strictEqual(stkInputValidate(pd, '1234').ok, true);
|
||||
assert.strictEqual(stkInputValidate(pd, '12').ok, false);
|
||||
assert.strictEqual(stkInputValidate(pd, '12345678901234567').ok, false);
|
||||
assert.strictEqual(stkInputValidate(pd, 'abcd').ok, false);
|
||||
// hidden entry allows only the digits set even without digits_only
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_input', hidden: true }, 'ab').ok, false);
|
||||
// GET INKEY: exactly one character, digits-only honours * # +
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'a').ok, true);
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'ab').ok, false);
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true);
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false);
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.6.37"
|
||||
version = "3.6.38"
|
||||
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.
|
||||
|
||||
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.6.37'
|
||||
VERSION = '3.6.38'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -2129,6 +2129,33 @@ def _decode_cmd(cmd_type, raw, qualifier):
|
||||
if cmd_type == 0x21:
|
||||
text = _parse_display_text(raw)
|
||||
return [{'label': 'Text', 'value': text}] if text else []
|
||||
if cmd_type in (0x22, 0x23):
|
||||
info = (_parse_get_inkey(raw) if cmd_type == 0x22
|
||||
else _parse_get_input(raw))
|
||||
if not info:
|
||||
return []
|
||||
out = [{'label': 'Text', 'value': info['text']}]
|
||||
if cmd_type == 0x22:
|
||||
kind = ('Yes/No' if info['yes_no'] else
|
||||
'digits only' if info['digits_only'] else
|
||||
'UCS2' if info['ucs2'] else 'SMS default alphabet')
|
||||
if info['immediate']:
|
||||
kind += ', immediate'
|
||||
else:
|
||||
kind = ('digits only' if info['digits_only'] else
|
||||
'UCS2' if info['ucs2'] else 'SMS default alphabet')
|
||||
if info['hidden']:
|
||||
kind += ', hidden'
|
||||
if info['packed']:
|
||||
kind += ', packed'
|
||||
max_text = 'no max' if info['max'] in (0, 0xFF) else str(info['max'])
|
||||
out.append({'label': 'Length', 'value': '%d..%s' % (info['min'], max_text)})
|
||||
if info.get('default'):
|
||||
out.append({'label': 'Default', 'value': info['default']})
|
||||
if info['help']:
|
||||
kind += ', help'
|
||||
out.append({'label': 'Response', 'value': kind})
|
||||
return out
|
||||
if cmd_type == 0x24:
|
||||
items = _parse_select_item(raw)
|
||||
if items:
|
||||
@@ -4252,6 +4279,70 @@ def _item_line(item):
|
||||
return line
|
||||
|
||||
|
||||
def _cmd_tlv_any(tlvs, tag):
|
||||
"""Command TLV lookup tolerating the comprehension-required variant in
|
||||
both directions (0x81/0x01, 0x8D/0x0D, 0x91/0x11, TS 101 220 7.1.1)."""
|
||||
return tlvs.get(tag) or tlvs.get(tag | 0x80) or tlvs.get(tag & 0x7F) or b''
|
||||
|
||||
|
||||
def _cmd_qualifier(tlvs):
|
||||
"""Command Qualifier byte of the Command details TLV (TS 102 223 8.6)."""
|
||||
cd = _cmd_tlv_any(tlvs, 0x81)
|
||||
return cd[2] if len(cd) >= 3 else None
|
||||
|
||||
|
||||
def _parse_get_inkey(raw):
|
||||
"""GET INKEY (0x22) fields: prompt text and the qualifier's request flags
|
||||
(TS 102 223 6.6.2, 8.6, 8.15). Returns None when the template is not a
|
||||
parseable GET INKEY."""
|
||||
tlvs = httpota.proactive_tlvs(raw)
|
||||
text = _cmd_tlv_any(tlvs, 0x8D) if tlvs else b''
|
||||
if not text:
|
||||
return None
|
||||
qual = _cmd_qualifier(tlvs)
|
||||
try:
|
||||
prompt = _decode_dcs_text(text)
|
||||
except Exception:
|
||||
return None
|
||||
return {
|
||||
'text': prompt,
|
||||
'digits_only': bool(qual is not None and not (qual & 0x01)),
|
||||
'ucs2': bool(qual is not None and (qual & 0x02)),
|
||||
'yes_no': bool(qual is not None and (qual & 0x04)),
|
||||
'immediate': bool(qual is not None and (qual & 0x08)),
|
||||
'help': bool(qual is not None and (qual & 0x80)),
|
||||
}
|
||||
|
||||
|
||||
def _parse_get_input(raw):
|
||||
"""GET INPUT (0x23) fields: prompt text, Response length (8.11), Default
|
||||
Text (8.23) and the qualifier's request flags (TS 102 223 6.6.3, 8.6).
|
||||
Returns None when the template is not a parseable GET INPUT."""
|
||||
tlvs = httpota.proactive_tlvs(raw)
|
||||
text = _cmd_tlv_any(tlvs, 0x8D) if tlvs else b''
|
||||
if not text:
|
||||
return None
|
||||
qual = _cmd_qualifier(tlvs)
|
||||
rl = _cmd_tlv_any(tlvs, 0x91)
|
||||
default = _cmd_tlv_any(tlvs, 0x9D)
|
||||
try:
|
||||
prompt = _decode_dcs_text(text)
|
||||
default_text = _decode_dcs_text(default) if default else None
|
||||
except Exception:
|
||||
return None
|
||||
return {
|
||||
'text': prompt,
|
||||
'min': rl[0] if len(rl) >= 1 else 0,
|
||||
'max': rl[1] if len(rl) >= 2 else 0xFF,
|
||||
'default': default_text,
|
||||
'digits_only': bool(qual is not None and not (qual & 0x01)),
|
||||
'ucs2': bool(qual is not None and (qual & 0x02)),
|
||||
'hidden': bool(qual is not None and (qual & 0x04)),
|
||||
'packed': bool(qual is not None and (qual & 0x08)),
|
||||
'help': bool(qual is not None and (qual & 0x80)),
|
||||
}
|
||||
|
||||
|
||||
def _parse_select_item(raw):
|
||||
items = []
|
||||
nai = None
|
||||
@@ -4474,25 +4565,128 @@ def _make_menu_fetch_handler(server, resp):
|
||||
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
|
||||
resp.update(type='select_item', items=items)
|
||||
return 'pause'
|
||||
elif cmd_type in (0x22, 0x23):
|
||||
# GET INKEY / GET INPUT: the user's answer is the TR's Text
|
||||
# string, so the panel must pause for it (v3.6.38)
|
||||
info = (_parse_get_inkey(raw) if cmd_type == 0x22
|
||||
else _parse_get_input(raw)) if raw else None
|
||||
if info:
|
||||
kind = 'get_inkey' if cmd_type == 0x22 else 'get_input'
|
||||
pending = dict(info, type=kind, cmd_num=cmd_num,
|
||||
cmd_type=cmd_type, dev_src=dev_src, dev_dst=dev_dst)
|
||||
dur = _find_duration(raw)
|
||||
pending['duration_unit'] = dur[0] if dur else None
|
||||
pending['shown_at'] = time.monotonic()
|
||||
server.stk_pending = pending
|
||||
resp.update(dict(info, type=kind))
|
||||
return 'pause'
|
||||
return _on_menu_fetch
|
||||
|
||||
|
||||
def _menu_send_response(server, result, item_id=None):
|
||||
# Hidden entry allows only the digits set (TS 102 223 6.4.3); digits-only
|
||||
# requests use the same set for both input commands (6.4.2/6.4.3).
|
||||
_DIGITS_CHARSET = re.compile(r'^[0-9*#+]*$')
|
||||
|
||||
|
||||
def _validate_input_response(pd, text):
|
||||
"""Validate the user's input against the pending GET INKEY / GET INPUT
|
||||
request (TS 102 223 6.4.2/6.4.3). Returns (text, error)."""
|
||||
text = '' if text is None else str(text)
|
||||
if pd['type'] == 'get_inkey':
|
||||
if pd.get('yes_no'):
|
||||
if text not in ('01', '00'):
|
||||
return None, 'yes/no response must be 01 (positive) or 00 (negative)'
|
||||
return text, None
|
||||
if len(text) != 1:
|
||||
return None, 'GET INKEY needs exactly one character'
|
||||
if pd.get('digits_only') and not _DIGITS_CHARSET.match(text):
|
||||
return None, 'digits only (0-9, *, #, +)'
|
||||
return text, None
|
||||
lo = int(pd.get('min', 0) or 0)
|
||||
hi = int(pd.get('max', 0xFF) or 0)
|
||||
if len(text) < lo:
|
||||
return None, 'at least %d character(s) required' % lo
|
||||
if hi not in (0, 0xFF) and len(text) > hi:
|
||||
return None, 'at most %d character(s) allowed' % hi
|
||||
if (pd.get('digits_only') or pd.get('hidden')) and not _DIGITS_CHARSET.match(text):
|
||||
return None, 'digits only (0-9, *, #, +)'
|
||||
return text, None
|
||||
|
||||
|
||||
def _pack_gsm7(septets):
|
||||
"""Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing)."""
|
||||
out = bytearray()
|
||||
acc = 0
|
||||
bits = 0
|
||||
for s in septets:
|
||||
acc |= (s & 0x7F) << bits
|
||||
bits += 7
|
||||
while bits >= 8:
|
||||
out.append(acc & 0xFF)
|
||||
acc >>= 8
|
||||
bits -= 8
|
||||
if bits:
|
||||
out.append(acc & 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _input_text_tlv(pd, text):
|
||||
"""Text string TLV (TS 102 223 8.15) carrying the user's answer for a GET
|
||||
INKEY / GET INPUT TERMINAL RESPONSE (6.8.5): the response is coded in the
|
||||
SMS default alphabet unpacked (DCS '04'), as UCS2 ('08') when the request
|
||||
asked for it, or packed ('00') for a packed GET INPUT."""
|
||||
if text == '':
|
||||
return bytes([0x8D, 0x00]) # null text string (empty input)
|
||||
if pd.get('yes_no'):
|
||||
body = bytes([0x04, 0x01 if text == '01' else 0x00])
|
||||
elif pd.get('ucs2'):
|
||||
body = bytes([0x08]) + text.encode('utf-16-be')
|
||||
elif pd.get('packed'):
|
||||
body = bytes([0x00]) + _pack_gsm7(text.encode('gsm03.38'))
|
||||
else:
|
||||
body = bytes([0x04]) + text.encode('gsm03.38')
|
||||
return bytes([0x8D, len(body)]) + body
|
||||
|
||||
|
||||
def _menu_send_response(server, result, item_id=None, text=None):
|
||||
"""Send the pending command's TERMINAL RESPONSE and continue the chain.
|
||||
Shared by /api/menu-respond and the user-input timeout watchdog. Returns
|
||||
(payload, http_status)."""
|
||||
Shared by /api/menu-respond and the user-input timeout watchdog. `text`
|
||||
is the user's answer for a pending GET INKEY / GET INPUT (v3.6.38).
|
||||
Returns (payload, http_status)."""
|
||||
if not server.stk_pending:
|
||||
return {'error': 'no pending command'}, 400
|
||||
scc = server.scc
|
||||
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
|
||||
RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12,
|
||||
'help': 0x13}
|
||||
gr = RESULT_MAP.get(result, 0x00)
|
||||
pd = server.stk_pending
|
||||
input_types = ('get_inkey', 'get_input')
|
||||
text_tlv = b''
|
||||
duration_tlv = b''
|
||||
if result == 'ok' and pd['type'] in input_types:
|
||||
text, err = _validate_input_response(pd, text)
|
||||
if err:
|
||||
return {'error': err}, 400
|
||||
try:
|
||||
text_tlv = _input_text_tlv(pd, text)
|
||||
except UnicodeEncodeError as e:
|
||||
return {'error': 'cannot encode the response text: %s' % e}, 400
|
||||
if pd.get('duration_unit') is not None and pd.get('shown_at') is not None:
|
||||
# TS 102 223 6.4.2/6.4.3: a variable-timeout request gets the
|
||||
# actual display/entry duration back, in the requested unit (8.8)
|
||||
elapsed = max(0.0, time.monotonic() - pd['shown_at'])
|
||||
value = int(round(elapsed * {0x00: 1 / 60.0, 0x01: 1.0,
|
||||
0x02: 10.0}.get(pd['duration_unit'], 1.0)))
|
||||
value = max(1, min(255, value))
|
||||
duration_tlv = bytes([0x04, 0x02, pd['duration_unit'], value])
|
||||
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
|
||||
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
|
||||
tr_data = cd + di
|
||||
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
|
||||
tr_data += bytes([0x90, 0x01, item_id])
|
||||
tr_data += bytes([0x83, 0x02, gr, 0x00])
|
||||
# Table 6.8.0 order after Result: Duration (D) then Text string (E)
|
||||
tr_data += duration_tlv + text_tlv
|
||||
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
|
||||
tr_rv = scc._tp.send_apdu(tr_hex)
|
||||
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
|
||||
@@ -5437,9 +5631,15 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(log)
|
||||
self._log_resp(log)
|
||||
elif self.path == '/api/stk-status':
|
||||
pd = self.server.stk_pending
|
||||
resp = {'active': self.server.menu_active,
|
||||
'pending': self.server.stk_pending is not None,
|
||||
'pending_type': self.server.stk_pending['type'] if self.server.stk_pending else None}
|
||||
'pending': pd is not None,
|
||||
'pending_type': pd['type'] if pd else None}
|
||||
if pd:
|
||||
# the panel renders a card-initiated pending command from this
|
||||
# (GET INKEY / GET INPUT prompt, SELECT ITEM items, ...)
|
||||
resp['pending_data'] = {k: v for k, v in pd.items()
|
||||
if k not in ('shown_at',)}
|
||||
self._send_json(resp)
|
||||
self._log_resp(resp)
|
||||
elif self.path in ('/api/scp81/status', '/api/bip/status'):
|
||||
@@ -6084,7 +6284,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
elif self.path == '/api/menu-respond':
|
||||
body = self._read_body()
|
||||
self._log_req(body)
|
||||
resp, code = _menu_send_response(self.server, body.get('result', 'ok'), body.get('item_id'))
|
||||
resp, code = _menu_send_response(self.server, body.get('result', 'ok'),
|
||||
body.get('item_id'), body.get('text'))
|
||||
self._send_json(resp, code)
|
||||
self._log_resp(resp)
|
||||
elif self.path == '/api/event-send':
|
||||
|
||||
@@ -46,6 +46,12 @@ from pysim_simple_server.server import (
|
||||
_spi_from_bytes,
|
||||
_split_secured_packet,
|
||||
_tr_data_only,
|
||||
_menu_send_response,
|
||||
_parse_get_input,
|
||||
_parse_get_inkey,
|
||||
_input_text_tlv,
|
||||
_validate_input_response,
|
||||
_pack_gsm7,
|
||||
SCP80_FIRST_BYTES,
|
||||
SCP80_MAX_SEGMENTS,
|
||||
SCP80_NEXT_BYTES,
|
||||
@@ -1651,6 +1657,154 @@ class RamCommandFormatTests(unittest.TestCase):
|
||||
self.assertEqual(len(state['steps']), 2)
|
||||
|
||||
|
||||
class StkInputTests(unittest.TestCase):
|
||||
"""GET INKEY / GET INPUT handling (v3.6.38, TS 102 223 6.4.2/6.4.3)."""
|
||||
|
||||
# The live GET INPUT from the RemMobileID menu (2026-09-29): qualifier
|
||||
# 0x04 = digits only + hidden entry, Response length 4..16.
|
||||
GET_INPUT = bytes.fromhex(
|
||||
'd03c8103012304820281828d2d0804120432043504340438044204350020004d006f00'
|
||||
'620069006c0065002d00490044002000500049004e003191020410')
|
||||
|
||||
def test_parse_get_input_reads_text_and_response_length(self):
|
||||
info = _parse_get_input(self.GET_INPUT)
|
||||
self.assertEqual(info['text'], 'Введите Mobile-ID PIN1')
|
||||
self.assertEqual((info['min'], info['max']), (4, 16))
|
||||
self.assertTrue(info['digits_only'] and info['hidden'])
|
||||
self.assertFalse(info['ucs2'] or info['packed'] or info['help'])
|
||||
self.assertIsNone(info['default'])
|
||||
|
||||
def test_parse_get_inkey_reads_the_qualifier_flags(self):
|
||||
# Command details: qualifier 0x84 = digits only + Yes/No, help bit set
|
||||
raw = bytes.fromhex('d0188103012284820281828d0d0804120435044004350434043a0430')
|
||||
info = _parse_get_inkey(raw)
|
||||
self.assertTrue(info['digits_only'])
|
||||
self.assertTrue(info['yes_no'])
|
||||
self.assertTrue(info['help'])
|
||||
self.assertFalse(info['ucs2'] or info['immediate'])
|
||||
# a template without a Text string is not a parseable input command
|
||||
self.assertIsNone(_parse_get_inkey(bytes.fromhex('d0038103012200')))
|
||||
|
||||
def test_decode_cmd_reports_the_input_request(self):
|
||||
decoded = _decode_cmd(0x23, self.GET_INPUT, 0x04)
|
||||
self.assertEqual(decoded[0], {'label': 'Text', 'value': 'Введите Mobile-ID PIN1'})
|
||||
self.assertEqual(decoded[1], {'label': 'Length', 'value': '4..16'})
|
||||
self.assertEqual(decoded[2], {'label': 'Response', 'value': 'digits only, hidden'})
|
||||
# max 0xFF = no maximum (8.11); a help flag is reported too
|
||||
raw = bytes.fromhex('d0118103012381820281828d020041910200ff')
|
||||
decoded = _decode_cmd(0x23, raw, 0x81)
|
||||
self.assertIn({'label': 'Length', 'value': '0..no max'}, decoded)
|
||||
self.assertEqual(decoded[-1], {'label': 'Response', 'value': 'SMS default alphabet, help'})
|
||||
|
||||
def test_input_text_tlv_codings(self):
|
||||
pd = {'type': 'get_input', 'digits_only': True, 'hidden': True}
|
||||
# digits: SMS default alphabet unpacked (DCS 04), one byte per char
|
||||
self.assertEqual(_input_text_tlv(pd, '1234').hex().upper(), '8D050431323334')
|
||||
# empty input: the null text string (6.8.5)
|
||||
self.assertEqual(_input_text_tlv(pd, '').hex().upper(), '8D00')
|
||||
# UCS2 request: DCS 08
|
||||
self.assertEqual(_input_text_tlv(dict(pd, ucs2=True, hidden=False), '12').hex().upper(),
|
||||
'8D050800310032')
|
||||
# packed request: DCS 00 + TS 23.038 septet packing
|
||||
self.assertEqual(_input_text_tlv(dict(pd, packed=True, hidden=False), '1234').hex().upper(),
|
||||
'8D050031D98C06')
|
||||
# GET INKEY Yes/No: value 01 positive / 00 negative (6.8.5)
|
||||
yesno = {'type': 'get_inkey', 'yes_no': True}
|
||||
self.assertEqual(_input_text_tlv(yesno, '01').hex().upper(), '8D020401')
|
||||
self.assertEqual(_input_text_tlv(yesno, '00').hex().upper(), '8D020400')
|
||||
|
||||
def test_pack_gsm7_matches_the_sms_packing(self):
|
||||
self.assertEqual(_pack_gsm7(b'1234').hex().upper(), '31D98C06')
|
||||
# 7 septets need 7 octets; the trailing bit is the last septet's MSB
|
||||
# (0 for '7' = 0x37), so the padding octet stays 00
|
||||
self.assertEqual(_pack_gsm7(b'1234567').hex().upper(), '31D98C56B3DD00')
|
||||
|
||||
def test_validate_input_response(self):
|
||||
pd = {'type': 'get_input', 'min': 4, 'max': 16, 'digits_only': True, 'hidden': True}
|
||||
self.assertEqual(_validate_input_response(pd, '1234'), ('1234', None))
|
||||
self.assertIsNotNone(_validate_input_response(pd, '12')[1])
|
||||
self.assertIsNotNone(_validate_input_response(pd, '1' * 17)[1])
|
||||
self.assertIsNotNone(_validate_input_response(pd, 'abcd')[1])
|
||||
# hidden entry allows only the digits set even without digits_only
|
||||
self.assertIsNotNone(_validate_input_response({'type': 'get_input', 'hidden': True}, 'ab')[1])
|
||||
# GET INKEY: exactly one character, digits-only honours * # +
|
||||
self.assertEqual(_validate_input_response({'type': 'get_inkey'}, 'a'), ('a', None))
|
||||
self.assertIsNotNone(_validate_input_response({'type': 'get_inkey'}, 'ab')[1])
|
||||
self.assertEqual(_validate_input_response({'type': 'get_inkey', 'digits_only': True}, '*'),
|
||||
('*', None))
|
||||
self.assertIsNotNone(_validate_input_response({'type': 'get_inkey', 'digits_only': True}, 'a')[1])
|
||||
# Yes/No: only 01 / 00
|
||||
yesno = {'type': 'get_inkey', 'yes_no': True}
|
||||
self.assertEqual(_validate_input_response(yesno, '01'), ('01', None))
|
||||
self.assertIsNotNone(_validate_input_response(yesno, 'yes')[1])
|
||||
|
||||
def test_menu_send_response_carries_the_input_text(self):
|
||||
class FakeTp:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_apdu(self, apdu):
|
||||
self.sent.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self, pending):
|
||||
self.stk_pending = pending
|
||||
self.menu_active = True
|
||||
self.scc = types.SimpleNamespace(cat_cla='80', _tp=FakeTp())
|
||||
|
||||
def pending(**over):
|
||||
pd = {'type': 'get_input', 'cmd_num': 1, 'cmd_type': 0x23,
|
||||
'dev_src': 0x81, 'dev_dst': 0x82, 'text': 'PIN',
|
||||
'min': 4, 'max': 16, 'default': None, 'digits_only': True,
|
||||
'ucs2': False, 'hidden': True, 'packed': False, 'help': False,
|
||||
'duration_unit': None, 'shown_at': None}
|
||||
pd.update(over)
|
||||
return pd
|
||||
|
||||
server = FakeServer(pending())
|
||||
resp, code = _menu_send_response(server, 'ok', None, '1234')
|
||||
self.assertEqual(code, 200, resp)
|
||||
apdu = server.scc._tp.sent[0].upper()
|
||||
self.assertIn('8D050431323334', apdu) # the entered digits
|
||||
self.assertIn('83020000', apdu) # successful result
|
||||
self.assertIsNone(server.stk_pending)
|
||||
# a rejected value sends nothing and keeps the command pending
|
||||
server2 = FakeServer(pending())
|
||||
resp2, code2 = _menu_send_response(server2, 'ok', None, 'abcd')
|
||||
self.assertEqual(code2, 400)
|
||||
self.assertIn('digits only', resp2['error'])
|
||||
self.assertEqual(server2.scc._tp.sent, [])
|
||||
# help is result 0x13 without a Text string
|
||||
server3 = FakeServer(pending(help=True))
|
||||
resp3, code3 = _menu_send_response(server3, 'help')
|
||||
self.assertEqual(code3, 200)
|
||||
self.assertIn('83021300', server3.scc._tp.sent[0].upper())
|
||||
|
||||
def test_menu_send_response_echoes_the_variable_timeout(self):
|
||||
class FakeTp:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def send_apdu(self, apdu):
|
||||
self.sent.append(apdu)
|
||||
return '', '9000'
|
||||
|
||||
server = types.SimpleNamespace(
|
||||
stk_pending={'type': 'get_input', 'cmd_num': 2, 'cmd_type': 0x23,
|
||||
'dev_src': 0x81, 'dev_dst': 0x82, 'text': 'PIN',
|
||||
'min': 4, 'max': 16, 'digits_only': True, 'hidden': True,
|
||||
'ucs2': False, 'packed': False, 'help': False,
|
||||
'duration_unit': 0x01, 'shown_at': 997.0},
|
||||
menu_active=True,
|
||||
scc=types.SimpleNamespace(cat_cla='80', _tp=FakeTp()))
|
||||
with mock.patch('time.monotonic', return_value=1000.0):
|
||||
resp, code = _menu_send_response(server, 'ok', None, '1234')
|
||||
self.assertEqual(code, 200, resp)
|
||||
# TS 102 223 6.8.4: 3 seconds in the requested unit (seconds)
|
||||
self.assertIn('04020103', server.scc._tp.sent[0].upper())
|
||||
|
||||
|
||||
class RamProgressTests(unittest.TestCase):
|
||||
"""Live RAM-operation progress exposed in /api/status (v3.6.34)."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user