v3.27.0: the outfile import
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.
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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, /<input type="file" id="cards-outfile"[^>]*onchange="cardsOutfileFile\(this\)"/);
|
||||
assert.match(html, /<div id="cards-outfile-panel"/);
|
||||
assert.match(html, /'Import outfile': 'Импорт outfile'/); // the term stays untranslated
|
||||
});
|
||||
|
||||
test('the preview and the import both post the outfile endpoint', () => {
|
||||
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(/<option value="(3des2|aes)"/g)].map(x => x[1]).sort();
|
||||
assert.deepStrictEqual(ui, server);
|
||||
});
|
||||
|
||||
test('cardsOutfileTarsFromForm fills the role defaults and keeps the free rows', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
|
||||
app.tars = 0;
|
||||
const tars = app.cardsOutfileTarsFromForm();
|
||||
assert.deepStrictEqual(tars.map(e => e.role), ['isd', 'uiccRfm', 'usimRfm']);
|
||||
assert.deepStrictEqual(tars.map(e => e.tar), ['000000', 'B00000', 'B00001']);
|
||||
assert.deepStrictEqual(tars.map(e => e.msl), ['16', '16', '16']);
|
||||
// a typed role value wins, a typed free row is kept, an empty one dropped
|
||||
setEl('cards-of-tar-0-tar', 'af4d01');
|
||||
setEl('cards-of-tar-0-msl', '0a');
|
||||
app.tars = 2;
|
||||
setEl('cards-of-tar-3-tar', 'b00011');
|
||||
setEl('cards-of-tar-3-msl', '16');
|
||||
setEl('cards-of-tar-3-desc', 'push');
|
||||
const tars2 = app.cardsOutfileTarsFromForm();
|
||||
assert.strictEqual(tars2.length, 4);
|
||||
assert.deepStrictEqual([tars2[0].tar, tars2[0].msl], ['AF4D01', '0A']);
|
||||
assert.deepStrictEqual([tars2[3].tar, tars2[3].msl, tars2[3].desc], ['B00011', '16', 'push']);
|
||||
assert.strictEqual(tars2[3].role, undefined);
|
||||
});
|
||||
|
||||
test('the TAR rows render the role ids and the free rows with a remove button', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, file: 'a.out', name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
|
||||
app.tars = 0;
|
||||
const rows = app.cardsOutfileTarRowsHtml();
|
||||
assert.match(rows, /id="cards-of-tar-0-tar"[^>]*value="000000"/);
|
||||
assert.match(rows, /id="cards-of-tar-2-msl"[^>]*value="16"/);
|
||||
assert.match(rows, /ISD compact format/);
|
||||
assert.ok(!rows.includes('cards-of-tar-3-tar'));
|
||||
// a free row carries the description input and the remove action
|
||||
app.tars = 1;
|
||||
const rows2 = app.cardsOutfileTarRowsHtml();
|
||||
assert.match(rows2, /id="cards-of-tar-3-tar"/);
|
||||
assert.match(rows2, /id="cards-of-tar-3-desc"/);
|
||||
assert.match(rows2, /onclick="cardsOutfileTarRemove\(3\)"/);
|
||||
});
|
||||
|
||||
test('cardsOutfileKeysets collects the parsed keyset numbers', () => {
|
||||
clearEls();
|
||||
app.state = {
|
||||
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge',
|
||||
preview: { presets: [
|
||||
{ keysets: [{ kic: '25' }, { kic: '15' }] },
|
||||
{ keysets: [{ kic: '15' }, { kic: '31' }] },
|
||||
] },
|
||||
};
|
||||
assert.deepStrictEqual(app.cardsOutfileKeysets(), [1, 2, 3]);
|
||||
app.state.preview = { presets: [] };
|
||||
assert.deepStrictEqual(app.cardsOutfileKeysets(), []);
|
||||
});
|
||||
|
||||
test('cardsOutfileResultHtml renders the report and the parsed cards', () => {
|
||||
clearEls();
|
||||
app.state = {
|
||||
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge',
|
||||
preview: {
|
||||
presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }], adm: '62329F37' }],
|
||||
report: { columns: ['IMSI', 'ICCID', 'KIC1', 'KID1'], rows: 2, cards: 1,
|
||||
keysets: ['KIC1/KID1'], skipped: ['line 3: no ICCID'], skipped_more: 1 },
|
||||
},
|
||||
};
|
||||
const out = app.cardsOutfileResultHtml();
|
||||
assert.match(out, /Columns: <span class="font-mono">IMSI ICCID KIC1 KID1<\/span>/);
|
||||
assert.match(out, /Cards: 1 \/ rows read: 2/);
|
||||
assert.match(out, /Keysets: <span class="font-mono">KIC1\/KID1<\/span>/);
|
||||
assert.match(out, /line 3: no ICCID/);
|
||||
assert.match(out, /\(\+1\)/);
|
||||
assert.match(out, /8970119000004600098/);
|
||||
assert.match(out, /15\/15/);
|
||||
assert.match(out, /ADM/);
|
||||
// an empty preview says so, an error is shown
|
||||
app.state.preview = null;
|
||||
assert.match(app.cardsOutfileResultHtml(), /Parsing\.\.\./);
|
||||
app.state.preview = { report: { error: 'no column-name line found' } };
|
||||
assert.match(app.cardsOutfileResultHtml(), /no column-name line found/);
|
||||
});
|
||||
|
||||
test('cardsOutfileControls reads the panel controls back into the state', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, name: 'old', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
|
||||
setEl('cards-of-name', ' Batch 7 ');
|
||||
setEl('cards-of-algo', 'aes');
|
||||
setEl('cards-of-mode', 'replace');
|
||||
setEl('cards-of-keyset', '2');
|
||||
app.cardsOutfileControls();
|
||||
assert.strictEqual(app.state.name, 'Batch 7');
|
||||
assert.strictEqual(app.state.algorithm16, 'aes');
|
||||
assert.strictEqual(app.state.mode, 'replace');
|
||||
assert.strictEqual(app.state.tarKeyset, 2);
|
||||
});
|
||||
|
||||
test('picking a file previews it with the file name and the TAR defaults', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.reply = { preview: true, presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }],
|
||||
report: { columns: ['ICCID'], rows: 1, cards: 1 } };
|
||||
setEl('cards-outfile-panel', '');
|
||||
const input = { value: 'C:\\fakepath\\batch.out', files: [{ name: 'batch.out', text: async () => TEXT }] };
|
||||
await app.cardsOutfileFile(input);
|
||||
assert.strictEqual(input.value, '');
|
||||
assert.strictEqual(app.calls.length, 1);
|
||||
assert.strictEqual(app.calls[0].path, '/api/presets/import-outfile');
|
||||
const body = app.calls[0].body;
|
||||
assert.strictEqual(body.text, TEXT);
|
||||
assert.strictEqual(body.name, 'batch'); // the file base name
|
||||
assert.strictEqual(body.algorithm16, '3des2');
|
||||
assert.strictEqual(body.mode, undefined); // the preview is a dry run
|
||||
assert.strictEqual(body.tar_keyset, undefined); // none resolved yet
|
||||
assert.strictEqual(body.tars.length, 3);
|
||||
assert.strictEqual(app.state.tarKeyset, 1); // the first parsed keyset
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
|
||||
});
|
||||
|
||||
test('a failing preview shows the server message in the panel', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.replyError = 'TAR must be three hex bytes: ZZ';
|
||||
setEl('cards-outfile-panel', '');
|
||||
const input = { value: '', files: [{ name: 'bad.out', text: async () => TEXT }] };
|
||||
await app.cardsOutfileFile(input);
|
||||
app.replyError = null;
|
||||
assert.match(app.cardsOutfileResultHtml(), /TAR must be three hex bytes/);
|
||||
});
|
||||
|
||||
test('the import posts the chosen mode and refreshes the list', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.fetched = 0;
|
||||
app.io = [];
|
||||
app.reply = { ok: true, preview: false, added: 2, skipped: 1, errors: ['dup'] };
|
||||
setEl('cards-outfile-panel', '');
|
||||
setEl('cards-of-mode', 'replace');
|
||||
setEl('cards-of-name', 'Batch 7');
|
||||
app.state = { text: TEXT, file: 'batch.out', name: 'Batch 7', algorithm16: '3des2',
|
||||
tarKeyset: 2, mode: 'merge', preview: { presets: [], report: {} } };
|
||||
await app.cardsOutfileImport();
|
||||
assert.strictEqual(app.calls.length, 1);
|
||||
assert.strictEqual(app.calls[0].body.mode, 'replace');
|
||||
assert.strictEqual(app.calls[0].body.tar_keyset, 2);
|
||||
assert.strictEqual(app.calls[0].body.name, 'Batch 7');
|
||||
assert.strictEqual(app.fetched, 1);
|
||||
assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]);
|
||||
assert.strictEqual(app.state, null); // the panel closed
|
||||
});
|
||||
Reference in New Issue
Block a user