Files
simple/frontend/tests/cards_outfile.test.js
T
catarrh 8a66ea5a0a v3.29.1: the per-row keysets in the outfile preview
The import applied one global default keyset to every TAR entry of every
imported card.  The preview's TAR table (TAR + MSL, the three role rows +
"+ TAR") now has a keyset select per row, mirroring the preset editor: an
empty "— none —" plus the keysets found in the file with their KIc/KID,
the first preselected, and the editor's amber warning when a non-zero MSL
has no keyset.  Each TAR's packets then use exactly the chosen keyset.

- PWA: the global "Default keyset for the TARs" select (and the request's
  tar_keyset) is gone; cardsOutfileTarsFromDom/FromForm carry each row's
  keyset; cardsOutfileKeysets() became cardsOutfileKeysetOptions() (the
  union with the KIc/KID bytes for the labels).
- Server: a `tars` entry's own `keyset` (1-15; an unusable value is a 400)
  wins, then the optional global `tar_keyset` (the API shorthand), then the
  card's first keyset - parse_outfile no longer clobbers per-entry keysets
  with the card-level fallback.  presets.normalise_keyset_ref() is
  module-level (one rule for the store and the parser).
- Docs: help EN/RU, api.md (the tars entry keyset + the global fallback),
  the repo AGENTS.  Tests: the per-entry/global/card fallbacks, the
  bad-keyset 400, the options helper, the row select and round-trip, the
  import body.  925 frontend + 944 Python; sw cache simple-v448.
2026-10-15 00:40:16 +03:00

332 lines
15 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';
}
const ASYNC_FNS = ['cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileImport'];
let code = '';
for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm',
'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd',
'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysetOptions',
'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, confirmResult: true };
var document = { getElementById: id => els[id] || null };
var t = k => k;
var alert = m => { sandbox.alerts.push(String(m)); };
var confirm = m => { sandbox.confirmCalls = (sandbox.confirmCalls || []); sandbox.confirmCalls.push(String(m)); return sandbox.confirmResult; };
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.cardsOutfileKeysetOptions = cardsOutfileKeysetOptions;
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 keysets travel per TAR row (v3.29.1): no global tar_keyset
assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset');
assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset');
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\)/);
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), mode: o\.mode/);
});
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\)"/);
// every row has a keyset select, fed by the parsed keysets (v3.29.1)
assert.match(rows2, /id="cards-of-tar-0-keyset"/);
assert.match(rows2, /id="cards-of-tar-3-keyset"/);
app.state.preview = { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] };
const rows3 = app.cardsOutfileTarRowsHtml();
assert.match(rows3, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
assert.match(rows3, /<option value="2">Keyset 2 — 25\/25<\/option>/);
// a non-zero MSL without a keyset gets the amber marker
app.state.preview = { presets: [] };
const rows4 = app.cardsOutfileTarRowsHtml();
assert.match(rows4, /This TAR needs a keyset/);
});
test('cardsOutfileKeysetOptions collects the parsed keysets with their bytes', () => {
clearEls();
app.state = {
text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
preview: { presets: [
{ keysets: [{ kic: '25', kid: '25' }, { kic: '15', kid: '15' }] },
{ keysets: [{ kic: '15', kid: '15' }, { kic: '31', kid: '31' }] },
] },
};
assert.deepStrictEqual(app.cardsOutfileKeysetOptions(),
[{ v: 1, kic: '15', kid: '15' }, { v: 2, kic: '25', kid: '25' },
{ v: 3, kic: '31', kid: '31' }]);
app.state.preview = { presets: [] };
assert.deepStrictEqual(app.cardsOutfileKeysetOptions(), []);
});
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', mode: 'merge', preview: null };
setEl('cards-of-name', ' Batch 7 ');
setEl('cards-of-algo', 'aes');
setEl('cards-of-mode', 'replace');
app.cardsOutfileControls();
assert.strictEqual(app.state.name, 'Batch 7');
assert.strictEqual(app.state.algorithm16, 'aes');
assert.strictEqual(app.state.mode, 'replace');
});
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); // the keysets are per row now
assert.strictEqual(body.tars.length, 3);
// the rows' keyset selects are filled from the parsed keysets (the first one
// is the default)
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/);
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
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.confirmResult = true;
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',
mode: 'merge', preview: { presets: [], report: {} } };
setEl('cards-of-tar-0-keyset', '2'); // the ISD row's chosen keyset
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, undefined); // per row, not global
assert.strictEqual(app.calls[0].body.tars[0].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
// the replace mode asks first, and a declined answer imports nothing
assert.deepStrictEqual(app.confirmCalls, ['Replace mode wipes the whole preset store. Continue?']);
});
test('a declined replace confirmation aborts the import', async () => {
clearEls();
app.calls = [];
app.confirmResult = false;
setEl('cards-of-mode', 'replace');
app.state = { text: TEXT, file: 'batch.out', name: 'x', algorithm16: '3des2',
tarKeyset: null, mode: 'merge', preview: { presets: [], report: {} } };
await app.cardsOutfileImport();
app.confirmResult = true;
assert.strictEqual(app.calls.length, 0);
assert.ok(app.state); // the panel stays open
// merge never asks
app.confirmResult = false;
setEl('cards-of-mode', 'merge');
app.reply = { ok: true, preview: false, added: 1, skipped: 0, errors: [] };
await app.cardsOutfileImport();
app.confirmResult = true;
assert.strictEqual(app.calls.length, 1);
});
test('a row keeps its chosen keyset through the DOM round-trip', () => {
clearEls();
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
preview: { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] } };
app.tars = 0;
setEl('cards-of-tar-0-keyset', '2'); // the user picked keyset 2 for the ISD row
setEl('cards-of-tar-1-keyset', '');
const tars = app.cardsOutfileTarsFromForm();
assert.strictEqual(tars[0].keyset, 2);
assert.strictEqual(tars[1].keyset, null);
// a free row carries its own keyset too
app.tars = 1;
setEl('cards-of-tar-3-tar', 'b00011');
setEl('cards-of-tar-3-msl', '1a');
setEl('cards-of-tar-3-keyset', '1');
const tars2 = app.cardsOutfileTarsFromForm();
assert.strictEqual(tars2.length, 4);
assert.strictEqual(tars2[3].keyset, 1);
});