diff --git a/docs/api.md b/docs/api.md index 3bc06bb..e91d628 100644 --- a/docs/api.md +++ b/docs/api.md @@ -36,6 +36,7 @@ a 3.x PWA). | `/api/presets/update` | POST | Update a preset (`{id, fields}`; the counter may be raised) | | `/api/presets/delete` | POST | Delete a preset (`{id}`) | | `/api/presets/import` | POST | Import presets (`{presets: [...], mode: merge\|replace}`) | +| `/api/presets/import-outfile` | POST | Import a card vendor's **outfile** (`{text, name?, algorithm16?, tars?, tar_keyset?, mode?}`; without `mode` a dry-run preview) | | `/api/tar-probe` | POST | Probe the card's registered OTA applications (one secured packet per TAR, TS 101 220 Annex D) | | `/api/counter-probe` | POST | Bounded counter sync: probe increasing counter values (start+1, +2, +4, …) with a read-only command until the card accepts one (ceiling/attempt-capped), then persist accepted+1 | | `/api/esim/chip` | GET | eUICC chip details (EID, EUICCInfo1/2, configured addresses) | @@ -278,8 +279,9 @@ import also accepts presets exported by the older localStorage-based builds. - `GET /api/presets` — `{"path": "…", "count": 2, "version": 5, "presets": [{…}]}` (card-free: it answers while a long card operation runs). - `POST /api/presets` — body = the preset fields (`keysets` included); returns - `{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced or raw EF - hex are normalised to one form) is refused with + `{"ok": true, "preset": {…}}`. A duplicate ICCID (digits, spaced digits, + digits with the odd-nibble F pad, or raw EF hex — all normalised to one + form) is refused with `400 {"error": "card with this ICCID already exists: …"}`. - `POST /api/presets/update` — `{"id": "…", "fields": {…}}` (partial update; a plain `{id, name, …}` body works too, `keysets` replaces the list and @@ -291,6 +293,70 @@ import also accepts presets exported by the older localStorage-based builds. `"replace"`) → `{"ok": true, "added": N, "skipped": M, "errors": […]}`; invalid entries and duplicates are skipped and reported, never fatal. +### `POST /api/presets/import-outfile` + +Import many cards at once from a card vendor's **outfile** (v3.27.0). An +outfile is the vendor personalization tool's plain-text dump: a banner and a +configuration header (customer, batch, profile, …) followed by the actual +data. The data starts at a **column-name line** — whitespace-separated +tokens that include `ICCID` (the first token may carry a `var_out:`-style +prefix) — and continues with one whitespace-separated row per card; +everything before the column line and every column the presets do not use is +ignored. + +Without a `mode` the call is a **dry run**: the parsed presets and the report +come back for the preview (nothing is stored). With `mode: "merge"` / +`"replace"` the same parse runs and the presets are imported through the +store (`replace` wipes the store first, like `/api/presets/import`). + +```json +{"text": "…", "name": "Batch 0002", "algorithm16": "3des2", + "tars": [{"role": "isd", "tar": "000000", "msl": "16", "desc": ""}, …], + "tar_keyset": 1, "mode": "merge"} +``` + +- `text` (required) — the outfile contents. A file without a column line + answers `{"ok": true, "preview": true, "presets": [], "report": {"error": + "no column-name line found - …"}}` (a preview, not a `400`: the panel + shows the message). +- `name` — one common name applied to every imported card; empty falls back + to the card's ICCID. +- `algorithm16` — the algorithm nibble for a **16-byte** key: `"3des2"` + (default, KIc/KID nibble `5`) or `"aes"` (nibble `2`). The key size alone + decides the others: 8 bytes → DES (`1`), 24 bytes → 3DES3 (`9`); any other + size skips that keyset with a report line. +- `tars` — the TAR table every imported card gets (defaults to the three role + entries with their spec TARs and MSL 16). A malformed table (a non-3-byte + TAR, a non-1-byte MSL, a duplicate) is refused with `400`. +- `tar_keyset` — one **global default keyset** (1–15) written into every TAR + entry of every imported card. Without it the card's first keyset is used + (a multi-keyset card would otherwise fail the store's "a non-zero MSL needs + a default keyset" rule). + +Recognised columns (case-insensitive): `ICCID` (the digits + odd-nibble F +pad, the plain digits, spaced digits or the raw nibble-swapped EF hex all +normalise to the same value), `PIN1`/`PIN2`, `PUK1`/`PUK2` (4–8 digits; an +invalid value is dropped and reported, the card is kept), `ADM` (the ASCII +string, hex-encoded — `3632333239463337` → `62329F37`), `PSK` (the SCP81 key, +exactly 32 hex chars) and `KICn`/`KIDn` — one keyset per numbered pair, up to +15. The **KIc/KID bytes are never in the file**: the keyset number is the +column number (`KIC2` → keyset 2) and the algorithm nibble follows the key +size, both bytes carrying the same number (TS 102 225 §A.2). A `PSKID` / +`PSK_ID` column names the PSK identity; without one the card's ICCID is used +(the identity the card sends in the ClientKeyExchange is ICCID-like). `IMSI*`, +`EKI` and `KIKn` (the key-management key, unused) are ignored. + +The response carries the parsed presets (a dry run) or the import result +(`added`/`skipped`/`errors`) plus the `report`: + +```json +{"ok": true, "preview": false, "added": 5, "skipped": 0, "errors": [], + "report": {"columns": ["IMSI", "ICCID", "KIC1", …], "header_lines": 11, + "rows": 5, "cards": 5, "keysets": ["KIC1/KID1", …], "tars": 3, + "skipped": ["line 4: keyset 2 has an unsupported key size (10 bytes)"], + "skipped_more": 0}} +``` + ### `POST /api/counter-probe` Sync a preset's SCP80 counter with the card. The card accepts only a counter diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 85e9b75..fb1b7b0 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -368,6 +368,7 @@

Столбец SCP81 показывает, задана ли в пресете рабочая пара PSK: ✓ (зелёный) — идентификатор и ключ заданы; ⚠ (жёлтый) — задано только одно из двух, и слушатель такой пресет игнорирует; — — PSK нет. Идентификатор и ключ PSK задаются вместе. Кнопка Добавить пресет открывает редактор с новым пресетом; Редактировать загружает пресет строки в редактор, поэтому поля можно менять без повторного ввода карты; Сохранить сохраняет и возвращает к списку, Отмена отменяет правки; Удалить удаляет строку (только в списке). Обмен пресетами: Экспорт в файл для выгрузки и Импорт из файла для загрузки. Выбранный пресет автоматически заполняет форму Secured Packet и устанавливает TAR для операции (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); TAR, введённый в форму Secured Packet вручную, сохраняется при отправке - счётчик, ключи и MSL этого TAR по-прежнему берутся из пресета; SPI1 берётся из MSL, а меньшее значение даёт предупреждение (сервер тоже возвращает его как warning); изменения сразу передаются работающему слушателю SCP81.

+

Импорт outfile загружает сразу много карт из outfile поставщика карт — текстового дампа инструмента персонализации: шапка с баннером и параметрами, затем разделённая пробелами строка колонок (среди токенов есть ICCID; первый токен может нести префикс вида var_out:) и по одной строке на карту. Всё до строки колонок и все неиспользуемые колонки игнорируются. Разбираются ICCID (цифры, цифры с добивочным F до чётного числа нибблов, цифры с разделителями или сырой hex EF — всё приводится к одной форме), PIN1/PIN2/PUK1/PUK2, ADM (строка ASCII, закодированная hex — 3632333239463337 это 62329F37), PSK (16 байт; необязательная колонка PSKID/PSK_ID задаёт её идентификатор, по умолчанию берётся ICCID карты) и нумерованные пары ключей KICn/KIDn — по одному keyset на пару, до 15. Байтов KIc/KID в файле нет: номер keyset — это номер колонки (KIC2 → keyset 2), а ниббл алгоритма определяется размером ключа — 8 байт DES, 24 байта 3DES3, а для 16 байт — выбором в предпросмотре: 3DES2 (по умолчанию) или AES; любой другой размер пропускает этот keyset с пометкой. TAR также не берутся из файла: предпросмотр показывает таблицу TAR, которую получат импортируемые карты (по умолчанию три ролевые записи с TAR по спецификации и MSL 16; их можно править, кнопка + TAR добавляет записи), и один keyset по умолчанию, применяемый ко всем записям TAR — он обязателен для карты с несколькими keysets (ненулевой MSL должен называть keyset). Общее имя применяется ко всем импортируемым картам (пустое — берётся ICCID); режим импорта выбирает Дополнить (по умолчанию — существующие пресеты сохраняются, дубли ICCID пропускаются и перечисляются) или Заменить (хранилище сначала очищается). Предпросмотр перечисляет разобранные карты (ICCID, keysets, найденные коды) и каждую пропущенную строку/ключ с причиной — до того, как что-либо сохранено.

diff --git a/frontend/help.html b/frontend/help.html index 3315a59..63dd047 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -367,6 +367,7 @@

The SCP81 column shows whether the preset supplies a usable PSK pair: ✓ (green) — identity and key set; ⚠ (amber) — only one of the two, so the listener ignores the preset; — — no PSK. PSK identity and key must be set together. Add preset opens the editor with a fresh preset; Edit loads the row's preset into it so fields can be changed without re-entering the card; Save stores it and returns to the list, Cancel discards the edits; Remove deletes the row (list only). Presets can be shared with Export to file and restored with Import from file. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a warning too); edits are pushed into a running SCP81 listener automatically.

+

Import outfile imports many cards at once from a card vendor's outfile — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated column line (the tokens include ICCID; the first token may carry a var_out:-style prefix) and one row per card. Everything before the column line and every unused column is ignored. The parser reads ICCID (digits, the digits with an odd-nibble F pad, spaced digits or the raw EF hex — all normalised to one form), PIN1/PIN2/PUK1/PUK2, ADM (the ASCII string, hex-encoded — 3632333239463337 is 62329F37), PSK (16 bytes; an optional PSKID/PSK_ID names its identity, defaulting to the card's ICCID) and the numbered key pairs KICn/KIDn — one keyset per pair, up to 15. The KIc/KID bytes are never in the file: the keyset number is the column number (KIC2 → keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for 16 bytes the preview's choice, 3DES2 (default) or AES; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with + TAR for extra entries) and one default keyset applied to every TAR entry — required for a card with several keysets (a non-zero MSL must name a keyset). A common name is applied to every imported card (empty falls back to the ICCID); Import mode chooses Merge (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or Replace (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.

diff --git a/frontend/index.html b/frontend/index.html index ddc212f..d93d63b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1613,6 +1613,7 @@ +
@@ -1628,7 +1629,9 @@
+ + @@ -1892,7 +1895,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.26.0'; +const SIMPLE_VERSION = '3.27.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -14155,7 +14158,13 @@ function cardsRebuildSelect() { function cardsNormIccid(v) { let s = String(v == null ? '' : v).trim().toUpperCase().replace(/[\s-]/g, ''); if (!s) return ''; - if (/[A-F]/.test(s)) s = decIccid(s); // raw EF content (nibble-swapped) + // The trailing F (or Fs) is the odd-nibble padding: when the rest is + // digits only the value is the plain digit form (the vendors' outfiles + // print it that way); otherwise it is the raw nibble-swapped EF content. + // A raw value never ends in F, so the rule is unambiguous (v3.27.0). + const stripped = s.replace(/F+$/, ''); + if (/[A-F]/.test(stripped)) s = decIccid(s); // raw EF content (nibble-swapped) + else s = stripped; return String(s || '').replace(/\D/g, '').replace(/^0+/, ''); } @@ -14512,6 +14521,292 @@ function cardsImportFile(input) { reader.readAsText(file); } +// ===== outfile import (v3.27.0) ===== +// A card vendor's outfile carries many cards (ICCID + KIc/KID keys + optional +// PIN/PUK/ADM/PSK). The server parses it (a dry run) and the panel below +// shows the preview plus the controls: the common name, the 16-byte key +// algorithm, the TAR table and its one global default keyset - the final call +// imports with a mode. +let _cardsOutfile = null; // {text, file, name, algorithm16, tarKeyset, mode, preview} +let _cardsOutfileTars = 0; // the free TAR rows of the panel + +function cardsOutfilePick() { + const el = document.getElementById('cards-outfile'); + if (!el) return; + el.value = ''; + el.click(); +} + +async function cardsOutfileFile(input) { + const file = input && input.files && input.files[0]; + if (input) input.value = ''; + if (!file) return; + let text = ''; + try { + text = await file.text(); + } catch (e) { + alert(t('Cannot read the file') + ': ' + e.message); + return; + } + _cardsOutfile = { text: text, file: String(file.name || ''), + name: String(file.name || '').replace(/\.[^.]+$/, ''), + algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null }; + _cardsOutfileTars = 0; + cardsOutfileRender(); + await cardsOutfilePreview(); +} + +function cardsOutfileClose() { + _cardsOutfile = null; + _cardsOutfileTars = 0; + const panel = document.getElementById('cards-outfile-panel'); + if (panel) { + panel.innerHTML = ''; + panel.classList.add('hidden'); + } +} + +// Read the panel's controls back into the state (called before any re-render). +function cardsOutfileControls() { + const o = _cardsOutfile; + if (!o) return; + const val = id => { const el = document.getElementById(id); return el ? String(el.value) : ''; }; + if (document.getElementById('cards-of-name')) o.name = val('cards-of-name').trim(); + if (document.getElementById('cards-of-algo')) o.algorithm16 = val('cards-of-algo') || '3des2'; + if (document.getElementById('cards-of-mode')) { + o.mode = val('cards-of-mode') === 'replace' ? 'replace' : 'merge'; + } + const ks = parseInt(val('cards-of-keyset'), 10); + if (Number.isInteger(ks) && ks > 0) o.tarKeyset = ks; +} + +// The TAR table as the panel shows it: the three role rows (their defaults +// when a field is empty) plus the free rows - the re-render source for +// Add/Remove, so a typed value is never lost. +function cardsOutfileTarsFromDom() { + const get = id => { + const el = document.getElementById(id); + return el ? String(el.value).trim() : ''; + }; + const out = CARDS_TAR_ROLES.map((role, i) => ({ + role: role, + tar: get('cards-of-tar-' + i + '-tar').replace(/\s/g, '').toUpperCase(), + msl: get('cards-of-tar-' + i + '-msl').replace(/\s/g, '').toUpperCase(), + desc: '', + })); + for (let i = 0; i < _cardsOutfileTars; i++) { + const idx = CARDS_TAR_ROLES.length + i; + out.push({ tar: get('cards-of-tar-' + idx + '-tar').replace(/\s/g, '').toUpperCase(), + msl: get('cards-of-tar-' + idx + '-msl').replace(/\s/g, '').toUpperCase(), + desc: get('cards-of-tar-' + idx + '-desc') }); + } + return out; +} + +// The TAR table as the preview sends it: the role defaults fill an emptied +// field, a fully empty free row is dropped; every entry gets the global +// default keyset server-side. +function cardsOutfileTarsFromForm() { + const rows = cardsOutfileTarsFromDom(); + const out = []; + for (let i = 0; i < rows.length; i++) { + const e = rows[i]; + if (e.role) { + out.push({ role: e.role, tar: e.tar || cardsRoleDefault(e.role), + msl: e.msl || CARDS_MSL_DEFAULT, desc: '' }); + } else if (e.tar || e.msl || e.desc) { + out.push(e); + } + } + return out; +} + +function cardsOutfileTarRowHtml(e, i) { + const cls = 'border border-gray-300 dark:border-slate-600 text-xs rounded px-1.5 py-0.5 dark:bg-slate-800 font-mono'; + const role = CARDS_TAR_ROLES[i] || ''; + let html = '
'; + html += ''; + html += ''; + if (role) { + html += '' + esc(CARDS_TAR_ROLE_DESC[role] || role) + ''; + } else { + html += ''; + html += ''; + } + return html + '
'; +} + +function cardsOutfileTarRowsHtml() { + const rows = cardsOutfileTarsFromDom(); + _cardsOutfileTars = Math.max(rows.length - CARDS_TAR_ROLES.length, 0); + const shown = CARDS_TAR_ROLES.map((role, i) => ({ + role: role, + tar: rows[i].tar || cardsRoleDefault(role), + msl: rows[i].msl || CARDS_MSL_DEFAULT, + })); + for (let i = 0; i < _cardsOutfileTars; i++) shown.push(rows[CARDS_TAR_ROLES.length + i]); + return shown.map((e, i) => cardsOutfileTarRowHtml(e, i)).join(''); +} + +function cardsOutfileTarAdd() { + const rows = cardsOutfileTarsFromDom(); + rows.push({ tar: '', msl: '', desc: '' }); + cardsOutfileTarRender(rows); +} + +function cardsOutfileTarRemove(i) { + if (i < CARDS_TAR_ROLES.length) return; + const rows = cardsOutfileTarsFromDom(); + rows.splice(i, 1); + cardsOutfileTarRender(rows); +} + +// A local re-render of the TAR rows only (the controls around them keep +// their values, so the preview is not re-run on every Add/Remove). +function cardsOutfileTarRender(rows) { + const box = document.getElementById('cards-of-tars'); + if (!box) return; + _cardsOutfileTars = Math.max((rows || []).length - CARDS_TAR_ROLES.length, 0); + const shown = CARDS_TAR_ROLES.map((role, i) => ({ + role: role, + tar: (rows && rows[i] && rows[i].tar) || cardsRoleDefault(role), + msl: (rows && rows[i] && rows[i].msl) || CARDS_MSL_DEFAULT, + })); + for (let i = 0; i < _cardsOutfileTars; i++) shown.push(rows[CARDS_TAR_ROLES.length + i]); + box.innerHTML = shown.map((e, i) => cardsOutfileTarRowHtml(e, i)).join(''); +} + +// The keyset numbers the parsed cards carry (the global default's options). +function cardsOutfileKeysets() { + const out = []; + (((_cardsOutfile || {}).preview || {}).presets || []).forEach(c => { + (c.keysets || []).forEach(ks => { + const v = spKeysetKvnOf(ks.kic); + if (v !== null && !out.includes(v)) out.push(v); + }); + }); + return out.sort((a, b) => a - b); +} + +function cardsOutfileResultHtml() { + const p = (_cardsOutfile || {}).preview; + if (!p) return '
' + esc(t('Parsing...')) + '
'; + const rep = p.report || {}; + let html = '
'; + if ((rep.columns || []).length) { + html += esc(t('Columns')) + ': ' + esc(rep.columns.join(' ')) + '
'; + } + html += esc(t('Cards')) + ': ' + (rep.cards || 0) + ' / ' + esc(t('rows read')) + ': ' + (rep.rows || 0); + if ((rep.keysets || []).length) { + html += ' · ' + esc(t('Keysets')) + ': ' + esc(rep.keysets.join(', ')) + ''; + } + html += '
'; + if (rep.error) { + html += '' + esc(rep.error) + '
'; + } + if ((rep.skipped || []).length || rep.skipped_more) { + html += '' + esc(t('skipped')) + ': ' + + esc((rep.skipped || []).join('; ')) + + (rep.skipped_more ? ' (+' + rep.skipped_more + ')' : '') + ''; + } + html += '
'; + if ((p.presets || []).length) { + html += '
'; + for (const c of p.presets) { + const ks = (c.keysets || []).map(k => esc(k.kic || '?') + '/' + esc(k.kid || '?')).join(' '); + const codes = [c.pin1 ? 'PIN1' : '', c.puk1 ? 'PUK1' : '', + c.adm ? 'ADM' : '', c.pskKey ? 'PSK' : ''].filter(Boolean).join(' '); + html += '' + + '' + + '' + + ''; + } + html += '
' + esc(c.iccid) + '' + ks + '' + esc(codes) + '
'; + } + return html; +} + +function cardsOutfileRender() { + const panel = document.getElementById('cards-outfile-panel'); + const o = _cardsOutfile; + if (!panel || !o) return; + const keysets = cardsOutfileKeysets(); + const selected = keysets.includes(o.tarKeyset) ? o.tarKeyset : (keysets[0] || null); + o.tarKeyset = selected; + let html = '
'; + html += '' + esc(t('outfile import')) + + ': ' + esc(o.file || '') + ''; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + html += '
' + esc(t('TAR table')) + '
'; + html += '
' + cardsOutfileTarRowsHtml() + '
'; + html += ''; + html += cardsOutfileResultHtml(); + html += '
'; + html += ''; + html += ''; + html += '
'; + panel.innerHTML = html; + panel.classList.remove('hidden'); +} + +async function cardsOutfilePreview() { + const o = _cardsOutfile; + if (!o) return; + cardsOutfileControls(); + const body = { + text: o.text, name: o.name, algorithm16: o.algorithm16, + tars: cardsOutfileTarsFromForm(), + }; + if (o.tarKeyset) body.tar_keyset = o.tarKeyset; + try { + o.preview = await pysimFetchOk('/api/presets/import-outfile', body); + } catch (e) { + o.preview = { report: { error: e.message } }; + } + cardsOutfileRender(); +} + +async function cardsOutfileImport() { + const o = _cardsOutfile; + if (!o) return; + cardsOutfileControls(); + const btn = document.getElementById('cards-of-import'); + if (btn) btn.disabled = true; + const body = { + text: o.text, name: o.name, algorithm16: o.algorithm16, + tars: cardsOutfileTarsFromForm(), mode: o.mode, + }; + if (o.tarKeyset) body.tar_keyset = o.tarKeyset; + try { + const res = await pysimFetchOk('/api/presets/import-outfile', body); + await cardsFetch(); + ioStatus('cards-io', 'Imported ' + res.added + ' cards' + + (res.skipped ? ' (' + res.skipped + ' skipped)' : '') + '.'); + cardsOutfileClose(); + } catch (e) { + alert('Import error: ' + e.message); + if (btn) btn.disabled = false; + } +} + // ===== pysim sub-tabs ===== let pysimCmdHistory = []; let pysimCmdHistIdx = -1; @@ -22975,6 +23270,21 @@ const LANG_RU = { 'Add': 'Добавить', 'Export to file': 'Экспорт в файл', 'Import from file': 'Импорт из файла', + 'Import outfile': 'Импорт outfile', + 'Cannot read the file': 'Не удалось прочитать файл', + 'Parsing...': 'Разбор…', + 'Columns': 'Колонки', + 'rows read': 'прочитано строк', + 'Keysets': 'Keysets', + 'outfile import': 'Импорт outfile', + 'Common name': 'Общее имя', + '16-byte keys': '16-байтные ключи', + 'Default keyset for the TARs': 'Keyset по умолчанию для TAR', + 'Import mode': 'Режим импорта', + 'Merge': 'Дополнить', + 'Replace': 'Заменить', + 'TAR table': 'Таблица TAR', + 'Refresh preview': 'Обновить предпросмотр', 'Name': 'Имя', 'ICCID': 'ICCID', 'Cntr': 'Сч', @@ -24234,6 +24544,8 @@ function refreshDynamicI18n() { if (isViewVisible('tab-cards')) { cardsRender(); cardsSetFormMode(); + // the outfile preview panel is built with t() strings too (v3.27.0) + if (_cardsOutfile) { cardsOutfileControls(); cardsOutfileRender(); } // the editor's dynamic rows carry t() strings (placeholders, titles): // rebuild them from what is typed, not from the defaults if (isViewVisible('cards-editor-view')) { diff --git a/frontend/sw.js b/frontend/sw.js index 8bae2f6..56b2f13 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v435'; +const CACHE = 'simple-v436'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/cards_iccid.test.js b/frontend/tests/cards_iccid.test.js index 979f846..f1f5970 100644 --- a/frontend/tests/cards_iccid.test.js +++ b/frontend/tests/cards_iccid.test.js @@ -73,6 +73,15 @@ test('cardsNormIccid accepts digits, separators and raw EF hex', () => { assert.strictEqual(cardsNormIccid(null), ''); }); +test('cardsNormIccid accepts the outfile digits+F form (v3.27.0)', () => { + // the vendors' outfiles print the ICCID as digits + an odd-nibble F pad - + // not the raw EF form (which never ends in F) + assert.strictEqual(cardsNormIccid(DIGITS + 'F'), DIGITS); + assert.strictEqual(cardsNormIccid(DIGITS + 'FF'), DIGITS); + assert.strictEqual(cardsNormIccid(DIGITS + 'f'), DIGITS); + assert.strictEqual(cardsNormIccid('FFFF'), ''); +}); + test('cardsFindByIccid matches regardless of the stored form', () => { globalThis.cards = [ { name: 'A', iccid: '1111111111111111111' }, diff --git a/frontend/tests/cards_outfile.test.js b/frontend/tests/cards_outfile.test.js new file mode 100644 index 0000000..58a91f8 --- /dev/null +++ b/frontend/tests/cards_outfile.test.js @@ -0,0 +1,269 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name, asyncFn) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); +} + +function extractConst(src, name) { + const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src); + if (!m) throw new Error('const ' + name + ' not found'); + return 'var ' + name + ' = ' + m[1] + ';\n'; +} + +const ASYNC_FNS = ['cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileImport']; +let code = ''; +for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm', + 'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd', + 'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysets', + 'cardsOutfileResultHtml', 'cardsOutfileControls', 'cardsOutfileClose', + 'cardsOutfileRender', 'cardsOutfileFile', 'cardsOutfilePreview', + 'cardsOutfileImport', 'esc', 'escHtml', 'cardsRoleDefault', 'spKeysetKvnOf']) { + code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n'; +} +code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + + extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_TAR_ROLE_DESC') + + 'var _cardsOutfile = null;\nvar _cardsOutfileTars = 0;\n' + code; + +// A minimal DOM: the tests set the elements they need, everything else is +// absent (the code guards every getElementById result). +const els = {}; +const app = new Function('els', ` + var sandbox = { calls: [], io: [], alerts: [], fetched: 0, reply: null, replyError: null }; + var document = { getElementById: id => els[id] || null }; + var t = k => k; + var alert = m => { sandbox.alerts.push(String(m)); }; + var ioStatus = (id, msg) => { sandbox.io.push([id, msg]); }; + var pysimFetchOk = async (path, body) => { + sandbox.calls.push({ path: path, body: body }); + if (sandbox.replyError) throw new Error(sandbox.replyError); + return sandbox.reply; + }; + var cardsFetch = async () => { sandbox.fetched++; }; + ${code} + sandbox.getState = () => _cardsOutfile; + sandbox.setState = v => { _cardsOutfile = v; }; + sandbox.getTars = () => _cardsOutfileTars; + sandbox.setTars = v => { _cardsOutfileTars = v; }; + Object.defineProperty(sandbox, 'state', { get: () => _cardsOutfile, set: v => { _cardsOutfile = v; } }); + Object.defineProperty(sandbox, 'tars', { get: () => _cardsOutfileTars, set: v => { _cardsOutfileTars = v; } }); + sandbox.cardsOutfileTarsFromDom = cardsOutfileTarsFromDom; + sandbox.cardsOutfileTarsFromForm = cardsOutfileTarsFromForm; + sandbox.cardsOutfileTarRowHtml = cardsOutfileTarRowHtml; + sandbox.cardsOutfileTarRowsHtml = cardsOutfileTarRowsHtml; + sandbox.cardsOutfileTarAdd = cardsOutfileTarAdd; + sandbox.cardsOutfileTarRemove = cardsOutfileTarRemove; + sandbox.cardsOutfileTarRender = cardsOutfileTarRender; + sandbox.cardsOutfileKeysets = cardsOutfileKeysets; + sandbox.cardsOutfileResultHtml = cardsOutfileResultHtml; + sandbox.cardsOutfileControls = cardsOutfileControls; + sandbox.cardsOutfileClose = cardsOutfileClose; + sandbox.cardsOutfileRender = cardsOutfileRender; + sandbox.cardsOutfileFile = cardsOutfileFile; + sandbox.cardsOutfilePreview = cardsOutfilePreview; + sandbox.cardsOutfileImport = cardsOutfileImport; + return sandbox; +`)(els); +app.state = null; +app.tars = 0; + +function setEl(id, value) { + els[id] = { value: value, innerHTML: '', classList: { add() {}, remove() {} } }; +} +function clearEls() { + for (const k of Object.keys(els)) delete els[k]; +} + +const TEXT = 'var_out:IMSI ICCID KIC1 KID1\n250197700000000 8970119000004600098F ' + + '00112233445566778899AABBCCDDEEFF FFEEDDCCBBAA99887766554433221100\n'; + +test('the Cards view wires the outfile button, the input and the panel', () => { + assert.match(html, /onclick="cardsOutfilePick\(\)"[^>]*data-l10n="Import outfile"/); + assert.match(html, /]*onchange="cardsOutfileFile\(this\)"/); + assert.match(html, /
{ + const preview = extractFunc(html, 'cardsOutfilePreview', true); + const imp = extractFunc(html, 'cardsOutfileImport', true); + assert.match(preview, /'\/api\/presets\/import-outfile'/); + assert.match(imp, /'\/api\/presets\/import-outfile'/); + assert.match(imp, /mode: o\.mode/); + // the keyset is only sent when one was resolved + assert.match(preview, /if \(o\.tarKeyset\) body\.tar_keyset = o\.tarKeyset;/); + assert.match(imp, /if \(o\.tarKeyset\) body\.tar_keyset = o\.tarKeyset;/); +}); + +test('the 16-byte algorithm values match the server map', () => { + const py = fs.readFileSync( + path.join(__dirname, '..', '..', 'pysim_simple_server', 'outfile.py'), 'utf8'); + const m = /_KEY_ALGO_16\s*=\s*\{([^}]*)\}/.exec(py); + assert.ok(m, '_KEY_ALGO_16 not found in outfile.py'); + const server = [...m[1].matchAll(/'([a-z0-9]+)'\s*:/g)].map(x => x[1]).sort(); + const ui = [...html.matchAll(/