cards: From card button + normalized duplicate ICCID refusal (v2.2.18)

- The card create/edit form gets a From card button next to the ICCID
  field: it fills the field with the equipped card's EF.ICCID. It uses the
  availability model through a new 'card-iccid' requirement - enabled only
  while /api/status reports a connected card with a readable iccid; with a
  card but no readable ICCID the tooltip says so, otherwise the usual
  'insert and equip' hint applies. The last status ICCID is kept in
  _pysimCardIccid (cleared on disconnect) so the click needs no round-trip.
- Duplicate ICCID refusal now compares normalized values via
  cardsNormIccid/cardsFindDuplicateIccid (digits, spaced digits and raw EF
  hex all identify the same card), skips the row being edited, ignores an
  empty field, and names the conflicting preset in the alert (translated).
- i18n RU: From card -> 'С карты', duplicate/unreadable alerts.
- Tests: cardsFindDuplicateIccid across formats and edit-skip,
  cardsIccidFromCard fill + alert path, form wiring guards, _pysimCardIccid
  tracking in the card-state harness, card-iccid availability gating in the
  indicator harness. 261 Python + 400 frontend tests green.
- Docs: help EN+RU Cards field table, READMEs, AGENTS. SW otaman-v185.
This commit is contained in:
2026-09-18 23:57:05 +03:00
parent 235388a5d0
commit ce7d91cfe0
11 changed files with 151 additions and 18 deletions
+1 -1
View File
@@ -449,7 +449,7 @@ Stores saved card configurations (presets) in `localStorage`. A preset holds the
The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓** (identity and key), **⚠** (only one of the two — the listener ignores such a preset), **—** (no PSK). Identity and key must be set together. The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓** (identity and key), **⚠** (only one of the two — the listener ignores such a preset), **—** (no PSK). Identity and key must be set together.
**Add a card:** fill in the name, ICCID (optional), SPI1/SPI2, KIc/KID keys and indices, TAR, the SCP81 PSK pair (optional) and click **Add**. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown. **Add a card:** fill in the name, ICCID (optional**From card** fills it from the equipped card's EF.ICCID), SPI1/SPI2, KIc/KID keys and indices, TAR, the SCP81 PSK pair (optional) and click **Add**. A duplicate ICCID (compared ignoring spaces and the raw-hex form) is refused, naming the conflicting preset. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown.
**Edit / remove:** **Edit** loads a preset into the form (the Add button becomes **Save**; **Cancel** clears the form); **Remove** deletes the row from `localStorage`. A successful SCP80 send advances and stores the replay counter, and edits are pushed into a running SCP81 listener automatically. **Edit / remove:** **Edit** loads a preset into the form (the Add button becomes **Save**; **Cancel** clears the form); **Remove** deletes the row from `localStorage`. A successful SCP80 send advances and stores the replay counter, and edits are pushed into a running SCP81 listener automatically.
+2 -2
View File
@@ -412,7 +412,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
| Поле | Описание | | Поле | Описание |
|---|---| |---|---|
| Name | Понятная метка (обязательна) | | Name | Понятная метка (обязательна) |
| ICCID | Опциональный идентификатор карты | | ICCID | Опциональный идентификатор карты; кнопка **С карты** подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается) |
| SPI1 / SPI2 | Уровень безопасности и настройки PoR | | SPI1 / SPI2 | Уровень безопасности и настройки PoR |
| Индекс KIc / KID | Номер версии ключа (задаётся вместе с ключами) | | Индекс KIc / KID | Номер версии ключа (задаётся вместе с ключами) |
| Ключ KIc / KID | Hex-ключи шифрования и MAC | | Ключ KIc / KID | Hex-ключи шифрования и MAC |
@@ -423,7 +423,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Столбец **SCP81** показывает, задана ли в пресете рабочая пара PSK: **✓** (идентификатор и ключ), **⚠** (только одно из двух — слушатель такой пресет игнорирует), **—** (PSK нет). Идентификатор и ключ задаются вместе. Столбец **SCP81** показывает, задана ли в пресете рабочая пара PSK: **✓** (идентификатор и ключ), **⚠** (только одно из двух — слушатель такой пресет игнорирует), **—** (PSK нет). Идентификатор и ключ задаются вместе.
**Добавить карту:** заполните имя, ICCID (опционально), SPI1/SPI2, ключи и индексы KIc/KID, TAR, при необходимости пару PSK и нажмите **Add**. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM. **Добавить карту:** заполните имя, ICCID (опционально — кнопка **С карты** подставляет EF.ICCID подключённой карты), SPI1/SPI2, ключи и индексы KIc/KID, TAR, при необходимости пару PSK и нажмите **Add**. Дубликат ICCID (сравнение без пробелов и с учётом сырой hex-формы) отклоняется с указанием конфликтующего пресета. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM.
**Изменить/удалить:** **Edit** загружает пресет в форму (кнопка Add становится **Save**; **Cancel** очищает форму); **Remove** удаляет строку из `localStorage`. Успешная отправка SCP80 увеличивает и сохраняет счётчик повторов, а изменения сразу передаются работающему слушателю SCP81. **Изменить/удалить:** **Edit** загружает пресет в форму (кнопка Add становится **Save**; **Cancel** очищает форму); **Remove** удаляет строку из `localStorage`. Успешная отправка SCP80 увеличивает и сохраняет счётчик повторов, а изменения сразу передаются работающему слушателю SCP81.
+1 -1
View File
@@ -303,7 +303,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Понятная метка (обязательна)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Понятная метка (обязательна)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Опциональный идентификатор карты</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Опциональный идентификатор карты. Кнопка <strong>С карты</strong> подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается); при сохранении ICCID, уже присутствующий в списке, отклоняется (сравнение без пробелов и с учётом сырой hex-формы, конфликтующий пресет называется)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Индикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
+1 -1
View File
@@ -303,7 +303,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody> <tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label (required)</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label (required)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier. <strong>From card</strong> fills it with the equipped card's EF.ICCID (enabled only while a readable ICCID is known); saving refuses an ICCID that already exists in the list (compared ignoring spaces and the raw-hex form, and the conflicting preset is named)</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr> <tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
+36 -5
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2"> <div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.2.17</span></h1> <h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.2.18</span></h1>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting..."> <span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span> <span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -1137,6 +1137,7 @@
<div class="flex gap-2 mb-3"> <div class="flex gap-2 mb-3">
<input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)"> <input id="cards-name" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="Name (e.g. Foobar SIM)">
<input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)"> <input id="cards-iccid" class="flex-1 border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 font-mono" placeholder="ICCID (optional)">
<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard()" class="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="From card">From card</button>
<button id="cards-add-btn" onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button> <button id="cards-add-btn" onclick="cardsAdd()" class="px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 whitespace-nowrap" data-l10n="Add">Add</button>
<button id="cards-cancel-btn" onclick="cardsCancelEdit()" class="hidden px-4 py-2.5 bg-gray-500 text-white text-sm font-medium rounded hover:bg-gray-600 whitespace-nowrap" data-l10n="Cancel">Cancel</button> <button id="cards-cancel-btn" onclick="cardsCancelEdit()" class="hidden px-4 py-2.5 bg-gray-500 text-white text-sm font-medium rounded hover:bg-gray-600 whitespace-nowrap" data-l10n="Cancel">Cancel</button>
</div> </div>
@@ -4711,6 +4712,7 @@ let _pysimServerAvailable = null; // null until probed
let _pysimCardEquipped = false; let _pysimCardEquipped = false;
let _pysimEquipping = false; let _pysimEquipping = false;
let _pysimAdmVerified = null; // null = no card session let _pysimAdmVerified = null; // null = no card session
let _pysimCardIccid = null; // EF.ICCID digits of the equipped card (null = unknown)
let _pysimAvailabilityTimer = null; let _pysimAvailabilityTimer = null;
function pysimAvailabilityState() { function pysimAvailabilityState() {
@@ -4725,7 +4727,9 @@ function pysimControlDisabled(needs, state) {
} }
function pysimNeedsHint(needs, state) { function pysimNeedsHint(needs, state) {
return state === 'server-down' ? t('Connect to server') : t('Insert and equip a card'); if (state === 'server-down') return t('Connect to server');
if (needs === 'card-iccid' && state === 'card') return t('Card equipped but its ICCID is not readable');
return t('Insert and equip a card');
} }
function pysimUpdateStateIndicator() { function pysimUpdateStateIndicator() {
@@ -4756,7 +4760,11 @@ function pysimApplyAvailability() {
const state = pysimAvailabilityState(); const state = pysimAvailabilityState();
document.querySelectorAll('[data-needs]').forEach(el => { document.querySelectorAll('[data-needs]').forEach(el => {
const needs = el.getAttribute('data-needs'); const needs = el.getAttribute('data-needs');
const disabled = pysimControlDisabled(needs, state); let disabled = pysimControlDisabled(needs, state);
if (needs === 'card-iccid') {
// From-card buttons need a readable ICCID, not just a session.
disabled = pysimControlDisabled('card', state) || !_pysimCardIccid;
}
el.disabled = disabled; el.disabled = disabled;
if (disabled) el.setAttribute('title', pysimNeedsHint(needs, state)); if (disabled) el.setAttribute('title', pysimNeedsHint(needs, state));
else el.removeAttribute('title'); else el.removeAttribute('title');
@@ -6015,8 +6023,10 @@ function cardsAdd() {
alert(t('PSK key must be 32 hex characters')); alert(t('PSK key must be 32 hex characters'));
return; return;
} }
if (v.iccid && cards.some((c, i) => c.iccid === v.iccid && i !== _cardsEditIdx)) { const dupIdx = cardsFindDuplicateIccid(v.iccid, _cardsEditIdx);
alert('Card with this ICCID already exists'); return; if (dupIdx >= 0) {
alert(t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name || ('Card ' + dupIdx)));
return;
} }
if (_cardsEditIdx !== null && cards[_cardsEditIdx]) { if (_cardsEditIdx !== null && cards[_cardsEditIdx]) {
cards[_cardsEditIdx] = Object.assign({}, cards[_cardsEditIdx], v); cards[_cardsEditIdx] = Object.assign({}, cards[_cardsEditIdx], v);
@@ -6133,6 +6143,23 @@ function cardsFindByIccid(iccid) {
return cards.findIndex(c => cardsNormIccid(c.iccid) === norm); return cards.findIndex(c => cardsNormIccid(c.iccid) === norm);
} }
function cardsFindDuplicateIccid(iccid, skipIdx) {
// Duplicate detection ignores the edited row and compares normalized
// values (digits, spaced digits or raw EF hex all mean the same card).
const norm = cardsNormIccid(iccid);
if (!norm) return -1;
return cards.findIndex((c, i) => i !== skipIdx && cardsNormIccid(c.iccid) === norm);
}
function cardsIccidFromCard() {
// Fill the form's ICCID from the equipped card (status.iccid). The button
// is only enabled while a readable ICCID is known; alert just in case the
// state changed between the last poll and the click.
const iccid = _pysimCardIccid;
if (!iccid) { alert(t('Card equipped but its ICCID is not readable')); return; }
document.getElementById('cards-iccid').value = iccid;
}
let _cardsAutoIccid = null; let _cardsAutoIccid = null;
function cardsAutoSelectByIccid(iccid) { function cardsAutoSelectByIccid(iccid) {
@@ -7646,6 +7673,7 @@ function pysimCardStateUpdate(status) {
_pysimServerAvailable = true; _pysimServerAvailable = true;
_pysimCardEquipped = !!status.connected; _pysimCardEquipped = !!status.connected;
_pysimEquipping = !!status.equipping; _pysimEquipping = !!status.equipping;
_pysimCardIccid = status.connected ? (status.iccid || null) : null;
pysimUpdateAdmIndicator(status); pysimUpdateAdmIndicator(status);
pysimApplyAvailability(); pysimApplyAvailability();
if (pysimProactiveSeqChanged(status.proactive_seq) if (pysimProactiveSeqChanged(status.proactive_seq)
@@ -11637,6 +11665,9 @@ const LANG_RU = {
'executed': 'выполнено', 'executed': 'выполнено',
'pending': 'ожидает', 'pending': 'ожидает',
'Name is required': 'Укажите имя', 'Name is required': 'Укажите имя',
'From card': 'С карты',
'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке',
'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается',
'Saved.': 'Сохранено.', 'Saved.': 'Сохранено.',
'Invalid APDU': 'Некорректный APDU', 'Invalid APDU': 'Некорректный APDU',
'Add at least one APDU': 'Добавьте хотя бы один APDU', 'Add at least one APDU': 'Добавьте хотя бы один APDU',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v184'; const CACHE = 'otaman-v185';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+15 -1
View File
@@ -24,7 +24,7 @@ function extractFunc(src, name) {
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n' let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n' + 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n' + 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n'
+ 'var _cardsAutoIccid = null;\n'; + 'var _cardsAutoIccid = null;\nvar _pysimCardIccid = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n'; code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n'; code += extractFunc(html, 'pysimControlDisabled') + '\n';
@@ -61,6 +61,7 @@ function setup() {
_pysimAdmVerified = null; _pysimAdmVerified = null;
_pysimServerAvailable = null; _pysimServerAvailable = null;
_cardsAutoIccid = null; _cardsAutoIccid = null;
_pysimCardIccid = null;
globalThis.document = { globalThis.document = {
getElementById: id => id === 'state-indicator-adm' ? adm : el, getElementById: id => id === 'state-indicator-adm' ? adm : el,
querySelectorAll: () => [], querySelectorAll: () => [],
@@ -122,6 +123,19 @@ test('a disconnect clears the ICCID auto-selection guard', () => {
assert.strictEqual(_cardsAutoIccid, null); assert.strictEqual(_cardsAutoIccid, null);
}); });
test('the last status ICCID is kept for the From card button', () => {
setup();
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
assert.strictEqual(_pysimCardIccid, '8970119000004600098');
// equipped but unreadable -> null (button disabled)
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: null }));
assert.strictEqual(_pysimCardIccid, null);
// disconnect clears it as well
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 6 }));
assert.strictEqual(_pysimCardIccid, null);
});
test('card session change triggers a data reset', () => { test('card session change triggers a data reset', () => {
const { calls } = setup(); const { calls } = setup();
pysimCardStateUpdate(status({ card_session: 3 })); pysimCardStateUpdate(status({ card_session: 3 }));
+57 -2
View File
@@ -23,7 +23,8 @@ function extractFunc(src, name) {
let code = ''; let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid', for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
'cardsNormIccid', 'cardsFindByIccid', 'cardsAutoSelectByIccid']) { 'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
'cardsIccidFromCard', 'cardsAutoSelectByIccid']) {
code += extractFunc(html, fn) + '\n'; code += extractFunc(html, fn) + '\n';
} }
eval(code); eval(code);
@@ -33,11 +34,18 @@ const RAW_HEX = '980711090000640090F8';
const DIGITS = '8970119000004600098'; const DIGITS = '8970119000004600098';
function fakeDoc() { function fakeDoc() {
const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' } }; const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' }, 'cards-iccid': { value: '' } };
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null };
return els; return els;
} }
function fakeAlert() {
const seen = [];
globalThis.alert = msg => seen.push(msg);
globalThis.t = s => s;
return seen;
}
test('cardsNormIccid accepts digits, separators and raw EF hex', () => { test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
assert.strictEqual(cardsNormIccid(DIGITS), DIGITS); assert.strictEqual(cardsNormIccid(DIGITS), DIGITS);
assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS); assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS);
@@ -104,6 +112,53 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1); assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
}); });
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
globalThis.cards = [
{ name: 'A', iccid: '1111111111111111111' },
{ name: 'B', iccid: RAW_HEX },
{ name: 'C', iccid: '' },
];
assert.strictEqual(cardsFindDuplicateIccid(DIGITS), 1);
assert.strictEqual(cardsFindDuplicateIccid('89 70 1190-0000 4600 098'), 1);
assert.strictEqual(cardsFindDuplicateIccid('1111111111111111111'), 0);
assert.strictEqual(cardsFindDuplicateIccid('2222222222222222222'), -1);
// empty ICCID is never a duplicate (the field is optional)
assert.strictEqual(cardsFindDuplicateIccid(''), -1);
assert.strictEqual(cardsFindDuplicateIccid(' '), -1);
});
test('cardsFindDuplicateIccid skips the row being edited', () => {
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
assert.strictEqual(cardsFindDuplicateIccid(DIGITS, 0), -1);
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 0), -1);
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 1), 0);
});
test('cardsIccidFromCard fills the field from the equipped card', () => {
const els = fakeDoc();
const seen = fakeAlert();
globalThis._pysimCardIccid = DIGITS;
cardsIccidFromCard();
assert.strictEqual(els['cards-iccid'].value, DIGITS);
assert.deepStrictEqual(seen, []);
// no readable ICCID (state raced the click): alert, leave the field alone
els['cards-iccid'].value = 'keep-me';
globalThis._pysimCardIccid = null;
cardsIccidFromCard();
assert.strictEqual(els['cards-iccid'].value, 'keep-me');
assert.deepStrictEqual(seen, ['Card equipped but its ICCID is not readable']);
});
test('the From card button and the duplicate refusal are wired into the form', () => {
assert.match(html, /<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard\(\)"/);
assert.ok(html.includes('data-l10n="From card"'));
// normalized duplicate check with the conflicting preset named
assert.ok(html.includes('cardsFindDuplicateIccid(v.iccid, _cardsEditIdx)'));
assert.ok(html.includes("t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name"));
assert.ok(html.includes("'From card': 'С карты'"));
assert.ok(html.includes("'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке'"));
});
test('the card-state update wires the ICCID into the preset selection', () => { test('the card-state update wires the ICCID into the preset selection', () => {
assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);')); assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);'));
assert.ok(html.includes('_cardsAutoIccid = null;')); assert.ok(html.includes('_cardsAutoIccid = null;'));
+35 -2
View File
@@ -21,8 +21,11 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1); return src.slice(m.index, i + 1);
} }
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n'; let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\nvar _pysimCardIccid = null;\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n'; code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n';
code += extractFunc(html, 'pysimNeedsHint') + '\n';
code += extractFunc(html, 'pysimApplyAvailability') + '\n';
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n'; code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += 'globalThis.t = s => s;\n'; code += 'globalThis.t = s => s;\n';
eval(code); eval(code);
@@ -38,6 +41,7 @@ function fakeEl() {
contains: c => classes.has(c), contains: c => classes.has(c),
}, },
setAttribute(k, v) { this.attrs[k] = v; }, setAttribute(k, v) { this.attrs[k] = v; },
getAttribute(k) { return this.attrs[k]; },
removeAttribute(k) { delete this.attrs[k]; }, removeAttribute(k) { delete this.attrs[k]; },
}; };
} }
@@ -49,10 +53,11 @@ function setup() {
'state-indicator-img': fakeEl(), 'state-indicator-img': fakeEl(),
}; };
els['state-indicator-img'].src = ''; els['state-indicator-img'].src = '';
globalThis.document = { getElementById: id => els[id] || null }; globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
_pysimServerAvailable = null; _pysimServerAvailable = null;
_pysimCardEquipped = false; _pysimCardEquipped = false;
_pysimEquipping = false; _pysimEquipping = false;
_pysimCardIccid = null;
return els; return els;
} }
@@ -127,6 +132,34 @@ test('indicator markup carries the dot and image elements', () => {
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/); assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
}); });
test('card-iccid controls need an equipped card with a readable ICCID', () => {
const check = (state, iccid) => {
const el = fakeEl();
el.setAttribute('data-needs', 'card-iccid');
globalThis.document = { querySelectorAll: () => [el], getElementById: () => null };
_pysimServerAvailable = state !== 'server-down';
_pysimCardEquipped = state === 'card';
_pysimCardIccid = iccid;
pysimApplyAvailability();
return el;
};
// no card -> disabled
let el = check('no-card', null);
assert.strictEqual(el.disabled, true);
assert.strictEqual(el.attrs.title, 'Insert and equip a card');
// equipped but unreadable ICCID -> disabled with its own hint
el = check('card', null);
assert.strictEqual(el.disabled, true);
assert.strictEqual(el.attrs.title, 'Card equipped but its ICCID is not readable');
// equipped with an ICCID -> enabled
el = check('card', '8970119000004600098');
assert.strictEqual(el.disabled, false);
assert.strictEqual(el.attrs.title, undefined);
// server down -> disabled
el = check('server-down', '8970119000004600098');
assert.strictEqual(el.disabled, true);
});
test('indicator image stays within the 32px header row budget', () => { test('indicator image stays within the 32px header row budget', () => {
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html); const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
assert.ok(m, 'inline image size not found'); assert.ok(m, 'inline image size not found');
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "2.2.17" version = "2.2.18"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+1 -1
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '2.2.17' VERSION = '2.2.18'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE