212bfc24ac
Import many card presets at once from a card vendor's plain-text dump. The new pure pysim_simple_server/outfile.py (parse_outfile) plus POST /api/presets/import-outfile: without `mode` the call is a dry-run preview returning the parsed presets and a report (nothing is stored), with `merge`/`replace` the same parse runs and the presets go through the preset store. The parser: the data starts at the first line whose whitespace tokens all look like column names and include ICCID (a leading `word:` prefix is stripped, so a `var_out:IMSI ICCID ...` header line matches while a config line naming a value - `ICCID:897...` or `ICCID 897...` - cannot); one row per card follows. Recognised columns (case-insensitive): ICCID, PIN1/PIN2, PUK1/PUK2, ADM (the ASCII string, hex-encoded), PSK (32 hex; an optional PSKID/PSK_ID names the identity, defaulting to the card's ICCID) and KICn/KIDn - one keyset per numbered pair, up to 15; IMSI*/EKI/KIKn are ignored. The KIc/KID bytes are inferred: the keyset number is the column number, the algorithm nibble follows the key size (8 B DES, 16 B 3DES2 or AES - the preview's choice, 3DES2 default, 24 B 3DES3; anything else skips the keyset with a note), both bytes carrying the same number (TS 102 225 A.2). The TARs never come from the file: the caller's table (default the three role entries) is applied with one global default keyset written into every entry (the card's first keyset when unset). An invalid optional code is dropped with a report line instead of failing the card; the report names the columns, the rows read, the parsed keysets and every skip. The ICCID normaliser fix (both sides, drift-guarded): the trailing F (or Fs) is the odd-nibble padding - when the rest is digits only it is the plain digit form (the vendors print the ICCID as digits+F), otherwise the value is the raw nibble-swapped EF hex; a raw value never ends in F, so the rule is unambiguous. PWA: the Import outfile button in the Cards list, the hidden file input and the preview panel (the common name, the 16-byte algorithm select, the TAR table with the role defaults editable and + TAR / remove for extras, the global default keyset select over the parsed keysets, the merge/replace mode, the parsed cards table, the report, Refresh preview, Import). The panel joins refreshDynamicI18n for the language switch. Docs: help EN/RU (the term outfile stays untranslated in RU), api.md's new section, the repo AGENTS. Tests: the parser (both fixture shapes, the column detection, the key-size inference, the options/TAR table, the PSK identity, the skips, the store round-trip), the endpoint (the preview, the import, the duplicate skip, the 400s), the normaliser on both sides, the PWA helpers/wiring and the algorithm16 drift guard against outfile.py. 904 frontend + 903 Python; sw cache simple-v436.
223 lines
9.1 KiB
JavaScript
223 lines
9.1 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 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';
|
||
}
|
||
|
||
function extractFunc(src, name) {
|
||
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 src.slice(m.index, i + 1);
|
||
}
|
||
|
||
let code = '';
|
||
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
|
||
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
|
||
'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender',
|
||
'esc', 'spKeysetList', 'spKeysetKvnOf',
|
||
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
|
||
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'cardsScp81Complete']) {
|
||
code += extractFunc(html, fn) + '\n';
|
||
}
|
||
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||
+ extractConst(html, 'CARDS_MSL_DEFAULT');
|
||
eval(code);
|
||
|
||
// live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad)
|
||
const RAW_HEX = '980711090000640090F8';
|
||
const DIGITS = '8970119000004600098';
|
||
|
||
function fakeDoc() {
|
||
const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' },
|
||
'tar-card-sel': { value: '' }, 'cards-iccid': { value: '' } };
|
||
globalThis.document = { getElementById: id => els[id] || null };
|
||
return els;
|
||
}
|
||
|
||
function fakeAlert() {
|
||
const seen = [];
|
||
globalThis.alert = msg => seen.push(msg);
|
||
globalThis.t = s => s;
|
||
return seen;
|
||
}
|
||
|
||
test('esc escapes quotes as well as the angle brackets', () => {
|
||
// the output is used inside attribute values (value="…", title="…",
|
||
// label="…") as well as in text contexts (v3.10.0 review fix)
|
||
assert.strictEqual(esc('a"b\'c<d>&'), 'a"b'c<d>&');
|
||
});
|
||
|
||
test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
|
||
assert.strictEqual(cardsNormIccid(DIGITS), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(RAW_HEX), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(RAW_HEX.toLowerCase()), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(''), '');
|
||
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' },
|
||
{ name: 'B', iccid: RAW_HEX },
|
||
{ name: 'C', iccid: '89 70 1190 0000 4600 098' },
|
||
];
|
||
assert.strictEqual(cardsFindByIccid(DIGITS), 1);
|
||
assert.strictEqual(cardsFindByIccid('1111111111111111111'), 0);
|
||
assert.strictEqual(cardsFindByIccid('2222222222222222222'), -1);
|
||
assert.strictEqual(cardsFindByIccid(''), -1);
|
||
});
|
||
|
||
test('cardsAutoSelectByIccid selects the preset in both SCP80 views', () => {
|
||
const els = fakeDoc();
|
||
const applied = [];
|
||
globalThis.cardsApply = idx => applied.push(idx);
|
||
globalThis.cards = [{ name: 'A', iccid: RAW_HEX }, { name: 'B', iccid: '' }];
|
||
globalThis._ramCardIdx = null;
|
||
globalThis._cardsAutoIccid = null;
|
||
const idx = cardsAutoSelectByIccid(DIGITS);
|
||
assert.strictEqual(idx, 0);
|
||
assert.strictEqual(els['sp-card-sel'].value, '0');
|
||
assert.strictEqual(els['ram-card-sel'].value, '0');
|
||
assert.strictEqual(els['tar-card-sel'].value, '0',
|
||
'the TAR probe view gets the equipped card\'s preset too');
|
||
assert.deepStrictEqual(applied, ['0']);
|
||
assert.strictEqual(globalThis._ramCardIdx, 0);
|
||
});
|
||
|
||
test('cardsAutoSelectByIccid does not override a manual choice for the same card', () => {
|
||
const els = fakeDoc();
|
||
const applied = [];
|
||
globalThis.cardsApply = idx => applied.push(idx);
|
||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
|
||
globalThis._ramCardIdx = null;
|
||
globalThis._cardsAutoIccid = null;
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||
// user picks the empty preset B for this card, then status polls repeat
|
||
els['sp-card-sel'].value = '1';
|
||
els['ram-card-sel'].value = '1';
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1);
|
||
assert.strictEqual(els['sp-card-sel'].value, '1');
|
||
assert.deepStrictEqual(applied, ['0']);
|
||
});
|
||
|
||
test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', () => {
|
||
fakeDoc();
|
||
globalThis.cardsApply = () => {};
|
||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '89390100000129506903' }];
|
||
globalThis._cardsAutoIccid = null;
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||
assert.strictEqual(cardsAutoSelectByIccid('89390100000129506903'), 1);
|
||
// ICCID not readable: reset the guard, keep the current selection
|
||
assert.strictEqual(cardsAutoSelectByIccid(null), -1);
|
||
assert.strictEqual(globalThis._cardsAutoIccid, null);
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||
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', () => {
|
||
assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);'));
|
||
assert.ok(html.includes('_cardsAutoIccid = null;'));
|
||
assert.ok(html.includes("getElementById('tar-card-sel')"),
|
||
'the auto-selection must cover the TAR probe view');
|
||
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
|
||
});
|
||
|
||
test('cardsRender highlights the equipped card preset', () => {
|
||
globalThis.t = s => s;
|
||
const els = { 'cards-tbody': { innerHTML: '' } };
|
||
globalThis.document = { getElementById: id => els[id] || null };
|
||
globalThis.cards = [
|
||
{ name: 'other', iccid: '1111111111111111111', keysets: [] },
|
||
{ name: 'live', iccid: DIGITS, keysets: [] },
|
||
];
|
||
globalThis._pysimCardIccid = DIGITS;
|
||
cardsRender();
|
||
let rows = els['cards-tbody'].innerHTML.split('</tr>');
|
||
assert.ok(!/bg-emerald-50/.test(rows[0]), 'the other preset must stay plain');
|
||
assert.match(rows[1], /bg-emerald-50/);
|
||
assert.match(rows[1], /The equipped card's preset/);
|
||
// the same card stored as raw EF hex matches too
|
||
globalThis.cards[1].iccid = RAW_HEX;
|
||
cardsRender();
|
||
rows = els['cards-tbody'].innerHTML.split('</tr>');
|
||
assert.match(rows[1], /bg-emerald-50/);
|
||
// no readable ICCID -> no highlight
|
||
globalThis._pysimCardIccid = null;
|
||
cardsRender();
|
||
assert.ok(!/bg-emerald-50/.test(els['cards-tbody'].innerHTML));
|
||
});
|