Files
simple/frontend/tests/cards_form.test.js
T
catarrh 9a688207ba v3.30.4: the in-app dialogs (native alert/confirm/prompt replaced)
The native browser dialogs block the page and look foreign inside the
app.  One shared modal (#pysim-dialog-modal) with the promise-based
helpers pysimAlert/pysimConfirm/pysimPrompt replaces all 71 call sites
(60 alert, 10 confirm, 1 prompt in profilerNew); deferredPrompt.prompt()
is the PWA install prompt and is untouched.

The helpers serialize through ONE promise queue (overlapping dialogs
appear in order, no message is overwritten).  Escape cancels; Enter
accepts except with the Cancel button focused (native behaviour); key
repeats/IME are ignored; a POINTER answer within 500 ms of a dialog
opening is dropped (the buttons keep the same position, so a
double-click's second click would otherwise answer the next queued
dialog - e.g. an alert queued before a destructive confirm).  Backdrop
clicks are ignored.  The dialog is role="dialog" aria-modal="true" and
traps Tab (the handler cycles the controls the dialog shows: alert OK;
confirm Cancel/OK; prompt Cancel/input/OK), so the focus can neither
leave the modal nor reach a background control (Space would activate
it).  When the modal is absent the helpers fall back to the native
dialogs - the Node tests extract single functions without the markup.

Alerts are fire-and-forget (a notification, not a decision); the 10
confirms and the prompt are awaited, which made five functions async
(scriptsDelete, pysimCustomRemove, profilerNew, profilerDelete,
snapshotDelete).  Because the modal no longer freezes the page, the
delete flows that mutated by index after the confirmation mutate by
identity now (profilerDelete/snapshotDelete/pysimCustomRemove/
testSuiteDeleteScript/testSuiteDelete): a list reorder during the open
dialog would otherwise delete the wrong entry.

The nine dialog messages that were still raw English literals are
t()-wrapped - three of them (No secured packet to verify/send, Card
reader server is not connected) activated RU entries that had existed
unused because they were never wrapped; Import error and Snapshot save
failed are new keys.

Drive-by: the batch modal's #ram-op-batch-info used whitespace-pre-line,
which has no rule in the prebuilt style.css (the v3.30.0 stale-style
trap), so its notice never line-broke - now whitespace-pre-wrap.

Tests: a new frontend/tests/dialogs.test.js (15 tests: the modal path,
the queue, the double-answer guard, the keyboard, the grace, the trap,
the fallback, the sweep pin, the stylesheet pin) plus the async/helper
updates in 8 existing test files.  945 frontend + 952 Python;
sw cache simple-v454.
2026-10-18 00:30:06 +03:00

719 lines
31 KiB
JavaScript

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';
}
let code = '';
const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove'];
for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove',
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective',
'spCntrAhead', 'spCntrLocalSync', 'ramSaveCntr',
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
'spTarKeyForPack', 'spPresetTar', 'packToSp',
'esc', 'escHtml',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText',
'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove',
'cardsFreeTarsFromForm', 'cardsTarsFromTable', 'cardsTarsFromForm', 'cardsTarsReset',
'cardsShowList', 'cardsShowEditor', 'cardsSetFormMode', 'cardsNew',
'cardsCancelEdit']) {
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 _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n'
+ 'globalThis.spPresetWarningRender = function() {};\n'
+ 'globalThis.spMslWarningRender = function() {};\n'
+ 'globalThis.pysimApplyAvailability = function() {};\n'
+ 'globalThis.navRecord = function() {};\n'
+ 'globalThis.navReplace = function() {};\n' + code;
eval(code);
globalThis.t = s => s;
// The dialog sweep replaced the native alert/confirm with the helpers: route
// them to the native stubs each test installs on globalThis.
globalThis.pysimAlert = m => globalThis.alert(m);
globalThis.pysimConfirm = async m => globalThis.confirm(m);
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
const CARD_IDS = ['cards-name','cards-iccid','cards-release','cards-pin1','cards-puk1','cards-pin2','cards-puk2','cards-adm',
'cards-tars','cards-list-view','cards-editor-view','cards-editor-title','cards-new-btn',
'cards-psk-id','cards-psk-key','cards-keysets',
'cards-add-btn','cards-cancel-btn',
// the keyset editor rows (two rows is enough for the tests)
'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key',
'cards-ks-0-kid-key','cards-ks-0-cntr',
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
'cards-ks-1-kid-key','cards-ks-1-cntr',
// the TAR table rows: 0-2 are the fixed role rows, 3+ the free rows
'cards-tr-0-tar','cards-tr-0-msl','cards-tr-1-tar','cards-tr-1-msl',
'cards-tr-2-tar','cards-tr-2-msl',
'cards-tr-3-tar','cards-tr-3-msl','cards-tr-3-desc','cards-tr-3-keyset',
'cards-tr-4-tar','cards-tr-4-msl','cards-tr-4-desc','cards-tr-4-keyset'];
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex'];
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
'ber-result','hota-preview'];
function ksRow(els, i, ks) {
for (const field of ['kic', 'kid', 'kic-key', 'kid-key', 'cntr']) {
const id = 'cards-ks-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl();
els[id].value = ks[field === 'kic-key' ? 'kicKey' : (field === 'kid-key' ? 'kidKey' : field)] || '';
}
}
function tarRow(els, i, e) {
for (const field of ['tar', 'msl', 'desc', 'keyset']) {
const id = 'cards-tr-' + i + '-' + field;
if (!els[id]) els[id] = fakeEl();
els[id].value = (e[field] === undefined || e[field] === null) ? '' : String(e[field]);
}
}
function presetFixture(extra) {
return Object.assign({
name: 'C',
keysets: [{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }],
tars: [{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }],
}, extra || {});
}
// A <select>-like fake (the TAR selector): setting innerHTML parses the option
// values (and the selected one) and selectedIndex moves the value, like a real
// element. The accessors need defineProperties - Object.assign would flatten
// them into data properties.
function selectEl() {
const el = fakeEl();
el.options = [];
el._sel = 0;
el._html = '';
el.disabled = false;
Object.defineProperties(el, {
innerHTML: {
get() { return this._html; },
set(v) {
this._html = v;
this.options = Array.from(v.matchAll(/value="([^"]*)"/g), m => ({ value: m[1] }));
const sel = v.match(/<option value="([^"]*)" selected/);
if (sel) this.value = sel[1];
},
},
selectedIndex: {
get() { return this._sel; },
set(i) { this._sel = i; if (this.options[i]) this.value = this.options[i].value; },
},
});
return el;
}
function fakeEl() {
const classes = new Set();
return {
value: '',
textContent: '',
innerHTML: '',
focus() {},
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
toggle: (c, on) => {
const want = on === undefined ? !classes.has(c) : !!on;
if (want) classes.add(c); else classes.delete(c);
return want;
},
contains: c => classes.has(c),
},
setAttribute() {}, removeAttribute() {},
};
}
let apiCalls = [];
function setup() {
const els = {};
for (const id of CARD_IDS.concat(SP_IDS, CHAIN_IDS)) els[id] = fakeEl();
els['sp-tar'] = selectEl(); // the TAR selector (v3.26.0)
globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
globalThis.cards = [];
globalThis._cardsEditIdx = null;
globalThis._ramCardIdx = null;
globalThis._cardsKeysetCount = 1;
_cardsTarCount = 0;
globalThis._spTarKey = 'isd';
globalThis.t = s => s;
globalThis.esc = s => s;
globalThis.escHtml = s => s;
globalThis.alert = () => {};
globalThis.cardsSave = () => {};
globalThis.cardsRender = () => {};
globalThis.cardsRebuildSelect = () => {};
globalThis.scp81PushPskMap = () => {};
globalThis.cardsFindDuplicateIccid = () => -1;
globalThis.ioStatus = () => {};
globalThis.switchTab = () => {};
globalThis.scp80SwitchSubtab = () => {};
globalThis.updateSpKic = () => {};
globalThis.updateSpKid = () => {};
globalThis.spInvalidate = () => {};
globalThis.updateSp = () => {};
globalThis.genSp = () => {};
globalThis._genSpBuild = () => {};
globalThis.spShowSizeInfo = () => {};
globalThis.ramRender = () => {};
globalThis.switchTab = () => {};
// the server store is the source of truth (v3.8.0): the Cards tab talks to
// /api/presets* and reloads through cardsFetch()
apiCalls = [];
globalThis.pysimFetchOk = async (path, body) => {
apiCalls.push({ path: path, body: body });
return { ok: true, added: 1, skipped: 0 };
};
globalThis.pysimFetch = async (path, body) => {
apiCalls.push({ path: path, body: body });
return { ok: true };
};
globalThis.cardsFetch = async () => true;
return els;
}
test('the preset TAR table helpers read the role entries and the MSLs', () => {
const p = presetFixture({ tars: [
{ role: 'isd', tar: 'AF4D01', msl: '1A' },
{ role: 'uiccRfm', tar: 'B00000', msl: '10' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' },
{ tar: 'AF4D02', msl: '0A', desc: 'applet' }] });
assert.strictEqual(cardsRoleTar(p, 'isd'), 'AF4D01');
assert.strictEqual(cardsTarMsl(p, 'af4d02'), '0A');
assert.strictEqual(cardsTarMsl(p, '123456'), '');
assert.strictEqual(cardsTarEntry(p, 'AF4D02').desc, 'applet');
// a missing role entry falls back to the spec default
assert.strictEqual(cardsRoleTar({}, 'uiccRfm'), 'B00000');
assert.strictEqual(cardsRoleTar(null, 'usimRfm'), 'B00001');
assert.strictEqual(cardsRoleDefault('nope'), '');
assert.strictEqual(cardsTarText(p),
'ISD AF4D01\u00b71A UICC B00000\u00b710 ADF B00001\u00b716 AF4D02\u00b70A (applet)');
assert.strictEqual(cardsTarText({}), '\u2014');
});
test('spMslWarningText compares the SPI1 with the TAR MSL numerically', () => {
const p = presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '10' },
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] });
assert.strictEqual(spMslWarningText(p, '000000', '10'), '');
assert.strictEqual(spMslWarningText(p, '000000', '1A'), '');
assert.match(spMslWarningText(p, '000000', '0A'), /MSL/);
// numeric, not lexicographic: '9' < '10'
assert.match(spMslWarningText(p, '000000', '9'), /MSL/);
// a TAR the preset does not carry (or a missing SPI1) is not a warning
assert.strictEqual(spMslWarningText(p, 'AF4D01', '00'), '');
assert.strictEqual(spMslWarningText(p, '000000', ''), '');
});
test('preset completeness predicates used by the header markers', () => {
const full = presetFixture({ pskIdentity: 'id', pskKey: 'KEY', adm: '0011' });
assert.ok(cardsScp80Complete(full));
assert.ok(cardsScp81Complete(full));
assert.ok(cardsAdmPresent(full));
// a complete keyset is required
assert.ok(!cardsScp80Complete(Object.assign({}, full, { keysets: [] })));
assert.ok(!cardsScp80Complete(Object.assign({}, full, {
keysets: [{ kic: '15', kid: '15', kicKey: '', kidKey: 'BB', cntr: '0000000001' }] })));
// every role entry needs its TAR and MSL
for (const role of ['isd', 'uiccRfm', 'usimRfm']) {
const noMsl = presetFixture({ tars: full.tars.map(e =>
e.role === role ? Object.assign({}, e, { msl: '' }) : e) });
assert.ok(!cardsScp80Complete(noMsl), 'SCP80 complete with an empty MSL for ' + role);
const noTar = presetFixture({ tars: full.tars.map(e =>
e.role === role ? Object.assign({}, e, { tar: '' }) : e) });
assert.ok(!cardsScp80Complete(noTar), 'SCP80 complete with an empty TAR for ' + role);
}
assert.ok(!cardsScp81Complete({ pskIdentity: 'id' }));
assert.ok(!cardsScp81Complete({ pskKey: 'KEY' }));
assert.ok(!cardsScp80Complete(null));
assert.ok(!cardsScp81Complete(null));
assert.ok(!cardsAdmPresent(null));
assert.ok(!cardsAdmPresent({ adm: ' ' }));
});
test('clearing the card form keeps the role TAR/MSL defaults', () => {
const els = setup();
els['cards-pin1'].value = '1234';
els['cards-adm'].value = 'DEAD';
els['cards-name'].value = 'X';
// a free row from a previous edit
tarRow(els, 3, { tar: 'AF4D01', msl: '0A', desc: 'applet' });
_cardsTarCount = 1;
cardsClearForm();
assert.strictEqual(els['cards-pin1'].value, '');
assert.strictEqual(els['cards-adm'].value, '');
assert.strictEqual(els['cards-name'].value, '');
assert.strictEqual(_cardsTarCount, 0, 'the free TAR rows are cleared');
// the role rows render the pre-settled defaults
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000000"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-msl"[^>]*value="16"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-1-tar"[^>]*value="B00000"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B00001"/);
// ... and the table reads back as the three role entries
assert.deepStrictEqual(cardsTarsFromForm(), [
{ role: 'isd', tar: '000000', msl: '16', desc: '', keyset: null },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: null },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '', keyset: null },
]);
});
test('editing prefills the code fields and renders the TAR table', () => {
const els = setup();
globalThis.cards = [
{ name: 'Legacy', keysets: [] },
presetFixture({ name: 'Full', adm: '0011', pin1: '1234', puk1: '12345678',
pin2: '0000', puk2: '87654321', tars: [
{ role: 'isd', tar: '000001', msl: '0A' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' },
{ tar: 'AF4D02', msl: '1A', desc: 'applet' }] }),
];
cardsEdit(0);
// the editor opens; missing codes read as empty
assert.ok(!els['cards-editor-view'].classList.contains('hidden'));
assert.ok(els['cards-list-view'].classList.contains('hidden'));
assert.strictEqual(els['cards-pin1'].value, '');
assert.strictEqual(els['cards-adm'].value, '');
cardsEdit(1);
assert.strictEqual(els['cards-pin1'].value, '1234');
assert.strictEqual(els['cards-puk1'].value, '12345678');
assert.strictEqual(els['cards-pin2'].value, '0000');
assert.strictEqual(els['cards-puk2'].value, '87654321');
assert.strictEqual(els['cards-adm'].value, '0011');
assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000001"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-1-tar"[^>]*value="B0000C"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B0000D"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-tar"[^>]*value="AF4D02"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-desc"[^>]*value="applet"/);
// the role rows carry the fixed descriptions and no remove button
assert.match(els['cards-tars'].innerHTML, /ISD compact format/);
assert.match(els['cards-tars'].innerHTML, /UICC shared FS RFM compact format/);
assert.match(els['cards-tars'].innerHTML, /ADF FS RFM compact format/);
assert.doesNotMatch(els['cards-tars'].innerHTML, /cardsTarRemove\(0\)/);
assert.match(els['cards-tars'].innerHTML, /cardsTarRemove\(3\)/);
});
test('the form values carry the codes and the TAR table', () => {
const els = setup();
els['cards-pin1'].value = ' 12 34 ';
els['cards-puk1'].value = '12345678';
els['cards-adm'].value = ' 00 11 22 33 ';
tarRow(els, 0, { tar: ' 000001 ', msl: '0a' });
tarRow(els, 1, { tar: '', msl: '' }); // uiccRfm -> spec default
tarRow(els, 2, { tar: 'B00003', msl: '1A' });
tarRow(els, 3, { tar: 'af4d02', msl: '0A', desc: 'My applet' });
_cardsTarCount = 1;
const v = cardsFormValues();
assert.deepStrictEqual(v.tars, [
{ role: 'isd', tar: '000001', msl: '0A', desc: '', keyset: null },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: null },
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '', keyset: null },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet', keyset: null },
]);
assert.strictEqual(v.pin1, '1234');
assert.strictEqual(v.puk1, '12345678');
assert.strictEqual(v.adm, '00112233');
});
test('the release field is collected, cleared and restored', () => {
const els = setup();
els['cards-release'].value = '14';
assert.strictEqual(cardsFormValues().release, '14');
cardsClearForm();
assert.strictEqual(els['cards-release'].value, '');
globalThis.cards = [presetFixture({ name: 'Rel', release: '17' })];
cardsEdit(0);
assert.strictEqual(els['cards-release'].value, '17');
// a preset without a release reads as unspecified
globalThis.cards = [presetFixture({ name: 'Plain' })];
cardsEdit(0);
assert.strictEqual(els['cards-release'].value, '');
});
test('saving a preset posts the form with the pre-settled TAR table', async () => {
const els = setup();
els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
// the editor's render preselects the only keyset on every TAR row
// (v3.26.0); the fake DOM is static, so the test does it explicitly
tarRow(els, 0, { keyset: 1 });
tarRow(els, 1, { keyset: 1 });
tarRow(els, 2, { keyset: 1 });
// the role fields are pre-settled by the markup; the test's fake elements
// start empty - the defaults fill in
els['cards-adm'].value = 'aa bb';
await cardsAdd();
assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls));
assert.strictEqual(apiCalls[0].path, '/api/presets');
assert.strictEqual(apiCalls[0].body.name, 'New card');
assert.deepStrictEqual(apiCalls[0].body.keysets, [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }]);
assert.deepStrictEqual(apiCalls[0].body.tars, [
{ role: 'isd', tar: '000000', msl: '16', desc: '', keyset: 1 },
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: 1 },
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '', keyset: 1 },
]);
assert.strictEqual(apiCalls[0].body.adm, 'AABB');
});
test('saving refuses a free TAR row without its MSL or with a duplicate TAR', async () => {
const els = setup();
els['cards-name'].value = 'New card';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
tarRow(els, 3, { tar: 'AF4D02', msl: '', desc: '' });
_cardsTarCount = 1;
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /MSL/);
// a TAR may appear once
seen.length = 0;
tarRow(els, 3, { tar: '000000', msl: '16', desc: '' });
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /Duplicate TAR/);
});
test('editing a preset updates it by its store id', async () => {
const els = setup();
globalThis.cards = [presetFixture({ id: 'abc123', name: 'Old' })];
globalThis._cardsEditIdx = 0;
els['cards-name'].value = 'Renamed';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
await cardsAdd();
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
assert.strictEqual(apiCalls[0].body.id, 'abc123');
assert.strictEqual(apiCalls[0].body.fields.name, 'Renamed');
assert.strictEqual(apiCalls[0].body.fields.tars.length, 3);
});
test('removing a preset deletes it in the store', async () => {
setup();
globalThis.cards = [{ id: 'abc123', name: 'A' }, { id: 'def456', name: 'B' }];
await cardsRemove(0);
assert.strictEqual(apiCalls.length, 1);
assert.strictEqual(apiCalls[0].path, '/api/presets/delete');
assert.strictEqual(apiCalls[0].body.id, 'abc123');
});
test('import posts the entries to the server store', async () => {
setup();
await cardsImport(JSON.stringify([
{ name: 'Imported', kic: '15', kid: '15', adm: ' aa bb ' },
{ name: 'Explicit', tar: '000009' },
]));
assert.strictEqual(apiCalls.length, 1);
assert.strictEqual(apiCalls[0].path, '/api/presets/import');
assert.strictEqual(apiCalls[0].body.presets.length, 2);
assert.strictEqual(apiCalls[0].body.presets[0].name, 'Imported');
// a payload that is not an array never reaches the server
apiCalls = [];
await cardsImport('{"nope": 1}');
assert.strictEqual(apiCalls.length, 0);
});
test('the SCP80 counter field is an override - an edit is never written to the store', () => {
const els = setup();
globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
els['sp-card-sel'].value = '0';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
els['sp-cntr'].value = '0000000009'; // an explicit override
// typing alone writes nothing (no spCntrSyncPreset anymore, v3.20.0)
assert.strictEqual(apiCalls.length, 0);
// a counter the server persisted: the local copy moves forward, display only
spCntrLocalSync('000000000A');
assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
assert.strictEqual(apiCalls.length, 0);
// and the copy never regresses (the store is monotonic)
spCntrLocalSync('0000000005');
assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
// the override in the field is untouched by the copy sync
assert.strictEqual(els['sp-cntr'].value, '0000000009');
});
test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => {
const els = setup();
globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '0A' },
{ role: 'usimRfm', tar: 'B0000D', msl: '1A' }] })];
globalThis._spTarKey = 'isd';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '000000');
assert.strictEqual(els['sp-spi1'].value, '16');
globalThis._spTarKey = 'uiccRfm';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000C');
assert.strictEqual(els['sp-spi1'].value, '0A');
globalThis._spTarKey = 'usimRfm';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B0000D');
assert.strictEqual(els['sp-spi1'].value, '1A');
// a preset without TAR entries (hand-made; the store fills the three
// roles): the selector has no options and the SPI1 falls back to the
// default MSL
globalThis.cards = [{ name: 'D', keysets: [], tars: [] }];
globalThis._spTarKey = 'isd';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, '');
assert.match(els['sp-tar'].innerHTML, /no TAR in the preset/);
assert.strictEqual(els['sp-spi1'].value, '16');
});
test('RAM card selection forces the ISD TAR', () => {
const els = setup();
globalThis.cards = [presetFixture()];
globalThis._spTarKey = 'usimRfm';
ramApplyCard('0');
assert.strictEqual(globalThis._spTarKey, 'isd');
assert.strictEqual(els['sp-tar'].value, '000000');
});
test('spTarKeyForPack maps each chain to its preset TAR role', () => {
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'isd');
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccRfm');
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimRfm');
assert.strictEqual(spTarKeyForPack('ber-result'), null);
assert.strictEqual(spTarKeyForPack('hota-preview'), null);
});
test('spPresetTar reads the selected preset and falls back to the spec defaults', () => {
const els = setup();
globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000A', msl: '16' }] })];
els['sp-card-sel'].value = '0';
assert.strictEqual(spPresetTar('uiccRfm'), 'B0000A');
assert.strictEqual(spPresetTar('usimRfm'), 'B00001');
els['sp-card-sel'].value = '';
assert.strictEqual(spPresetTar('uiccRfm'), 'B00000');
});
test('the TAR selector lists the preset table', () => {
// v3.26.0: the TAR is a select of the preset's entries (role label or
// description, MSL and default keyset in the option text)
const els = setup();
spTarRender(presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }));
assert.match(els['sp-tar'].innerHTML, /value="000000"[^>]*>000000 \u00b7 ISD \u00b7 MSL 16 \u00b7 Keyset 1</);
assert.match(els['sp-tar'].innerHTML, /value="AF4D02"[^>]*>AF4D02 \u00b7 My applet \u00b7 MSL 0A</);
spTarRender(null);
assert.match(els['sp-tar'].innerHTML, /select a card preset/);
assert.strictEqual(els['sp-tar'].disabled, true);
});
test('packing a chain selects the TAR of its target', () => {
const els = setup();
globalThis.cards = [presetFixture({ tars: [
{ role: 'isd', tar: '000000', msl: '16' },
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
{ role: 'usimRfm', tar: 'B0000D', msl: '16' }] })];
els['sp-card-sel'].value = '0';
els['chain-sim-preview'].value = 'A0A4000002';
packToSp('chain-sim-preview');
assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
assert.strictEqual(els['sp-tar'].value, 'B0000C');
assert.strictEqual(els['sp-apdu'].value, 'A0A4000002');
els['chain-usim-preview'].value = '00A4000002';
packToSp('chain-usim-preview');
assert.strictEqual(globalThis._spTarKey, 'usimRfm');
assert.strictEqual(els['sp-tar'].value, 'B0000D');
els['chain-ram-preview'].value = '80E29000';
packToSp('chain-ram-preview');
assert.strictEqual(globalThis._spTarKey, 'isd');
assert.strictEqual(els['sp-tar'].value, '000000');
// BER / HTTP OTA packs leave the TAR alone
els['sp-tar'].value = 'ABCDEF';
els['ber-result'].value = 'AA0100';
packToSp('ber-result');
assert.strictEqual(els['sp-tar'].value, 'ABCDEF');
assert.strictEqual(els['sp-apdu'].value, 'AA0100');
});
test('packing without a selected preset keeps the role for the next apply', () => {
// v3.26.0: the TAR options come from the preset - without one the selector
// stays on its placeholder and the role is applied when a preset arrives
const els = setup();
els['chain-sim-preview'].value = 'A0A4000002';
packToSp('chain-sim-preview');
assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
assert.strictEqual(els['sp-tar'].value, '', 'no preset -> no TAR options yet');
globalThis.cards = [presetFixture()];
els['sp-card-sel'].value = '0';
cardsApply(0);
assert.strictEqual(els['sp-tar'].value, 'B00000');
});
test('the TAR row values are left-aligned like the headers', () => {
const out = cardsTarRowHtml({ tar: 'AF4D01', msl: '0A', desc: 'x' }, 3);
assert.doesNotMatch(out, /text-center/);
assert.match(out, /<td class="py-1 px-2"><input id="cards-tr-3-tar" class="w-full/);
assert.match(out, /<td class="py-1 px-2"><input id="cards-tr-3-msl" class="w-full/);
});
test('the keyset row renders as a table row with full-width inputs', () => {
const out = cardsKeysetRowHtml({ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }, 0);
assert.match(out, /^<tr>/);
assert.match(out, /<td class="py-1 px-2"><span id="cards-ks-0-kvn"/);
assert.match(out, /<td class="py-1 px-2"><input id="cards-ks-0-kic" class="w-full/);
assert.match(out, /<td class="py-1 px-2"><input id="cards-ks-0-kic-key" class="w-full/);
assert.match(out, /<td class="py-1 px-2"><input id="cards-ks-0-cntr" class="w-full/);
assert.match(out, /cardsKeysetRemove\(0\)/);
});
test('the TAR table renders the role rows and adds/removes free rows', () => {
const els = setup();
cardsTarRowsRender([{ tar: 'AF4D01', msl: '0A', desc: 'one' },
{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
assert.strictEqual(_cardsTarCount, 2);
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value=""/);
assert.match(els['cards-tars'].innerHTML, /ISD compact format/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-tar"[^>]*value="AF4D01"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-4-tar"[^>]*value="AF4D02"/);
assert.match(els['cards-tars'].innerHTML, /cards-tr-4-desc"[^>]*value="two"/);
assert.match(els['cards-tars'].innerHTML, /cardsTarRemove\(4\)/);
// simulate the DOM the innerHTML produced
tarRow(els, 3, { tar: 'AF4D01', msl: '0A', desc: 'one' });
tarRow(els, 4, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarRemove(3);
assert.strictEqual(_cardsTarCount, 1);
assert.match(els['cards-tars'].innerHTML, /value="AF4D02"/);
assert.doesNotMatch(els['cards-tars'].innerHTML, /value="AF4D01"/);
tarRow(els, 3, { tar: 'AF4D02', msl: '0A', desc: 'two' });
cardsTarAdd();
assert.strictEqual(_cardsTarCount, 2);
// the new row renders empty
tarRow(els, 4, { tar: '', msl: '', desc: '' });
assert.deepStrictEqual(cardsFreeTarsFromForm(),
[{ tar: 'AF4D02', msl: '0A', desc: 'two', keyset: null }]);
// the role rows are never removable
cardsTarRemove(0);
assert.strictEqual(_cardsTarCount, 2);
// an emptied role field survives a re-render (no silent default refill),
// while the save path still fills the default
tarRow(els, 0, { tar: '', msl: '' });
cardsTarRemove(3);
assert.strictEqual(_cardsTarCount, 0);
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value=""/);
assert.strictEqual(cardsTarsFromForm()[0].tar, '000000');
assert.strictEqual(cardsTarsFromForm()[0].msl, '16');
});
test('the list view opens the editor for a new preset and Cancel returns', () => {
const els = setup();
globalThis.cards = [presetFixture()];
cardsNew();
assert.ok(!els['cards-editor-view'].classList.contains('hidden'), 'the editor is shown');
assert.ok(els['cards-list-view'].classList.contains('hidden'), 'the list is hidden');
assert.strictEqual(globalThis._cardsEditIdx, null);
assert.strictEqual(els['cards-editor-title'].textContent, 'Add preset');
assert.strictEqual(els['cards-add-btn'].textContent, 'Add');
assert.strictEqual(_cardsTarCount, 0);
cardsCancelEdit();
assert.ok(!els['cards-list-view'].classList.contains('hidden'), 'the list is back');
assert.ok(els['cards-editor-view'].classList.contains('hidden'), 'the editor is hidden');
});
test('editing opens the editor with the preset title', () => {
const els = setup();
globalThis.cards = [presetFixture({ name: 'Foo' })];
cardsEdit(0);
assert.strictEqual(globalThis._cardsEditIdx, 0);
assert.ok(!els['cards-editor-view'].classList.contains('hidden'));
assert.strictEqual(els['cards-editor-title'].textContent, 'Edit preset: Foo');
assert.strictEqual(els['cards-add-btn'].textContent, 'Save');
});
test('the editor refuses PIN/PUK codes that are not 4-8 digits', async () => {
const els = setup();
els['cards-name'].value = 'X';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
els['cards-pin1'].value = '123';
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /PIN\/PUK/);
seen.length = 0;
els['cards-pin1'].value = '1234';
els['cards-puk2'].value = '12A4';
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /PUK2/);
});
test('the editor refuses a counter that is not 1-10 hex digits', async () => {
const els = setup();
els['cards-name'].value = 'X';
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: 'XYZ' });
globalThis._cardsKeysetCount = 1;
const seen = [];
globalThis.alert = msg => seen.push(msg);
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /Counter must be 1-10 hex digits/);
// a valid value - spaces included, the server strips them - is posted
seen.length = 0;
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '00 00 00 00 01' });
await cardsAdd();
assert.strictEqual(apiCalls.length, 1, JSON.stringify(seen));
assert.strictEqual(apiCalls[0].body.keysets[0].cntr, '00 00 00 00 01');
// an empty counter is refused too (the value is required)
seen.length = 0;
apiCalls.length = 0;
els['cards-name'].value = 'X';
globalThis._cardsKeysetCount = 1;
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' });
await cardsAdd();
assert.strictEqual(apiCalls.length, 0);
assert.match(seen[0], /Counter must be 1-10 hex digits/);
});