Files
simple/frontend/tests/stk_params.test.js
T
catarrh ab373aff30 v3.28.0: the batch CAP install
The bulk workflow: one .cap and one set of install parameters applied to
card after card - swap cards and each one installs as soon as it is
equipped.  The server owns the loop, so closing the tab or reloading the
page does not interrupt it.

The install chain is now shared: _ram_install_plan(body) builds the
card-independent part (the parsed .cap, the INSTALL/LOAD sequence, the
block size, the transport knobs) and _ram_install_run(server, body, plan,
scc) runs one chain (the per-card preset/keyset/counter resolution
included); the /api/ram-install handler is the plan + run + response, and
the batch worker calls the same pair.  The plan is built once at Start,
so a bad .cap or parameter combination is a 400 there (a malformed
archive included - a zip parse error is the caller's, never a dropped
connection), not a per-card failure.  _op_keyset_resolve() is the module
form of the handler's _op_keyset() (the dead _keyset_guard method is
gone).

The worker (_ram_batch_worker, state _RAM_BATCH) polls the card state
every 0.5 s and acts on a new card session while connected; a card
already equipped at Start installs immediately.  Per card the preset is
matched by ICCID and its ISD role TAR supplies the keyset (v3.26.0) and
the counter seed (v3.20.0); a card with no readable ICCID, no matching
preset or no usable ISD keyset is recorded as skipped with its reason.
A card is attempted once per run (any outcome): re-equipping it records
"already processed in this batch (<outcome>)" and never installs again -
restarting the batch is the retry.  Failures carry the failing step and
the remote SW, are never retried and never stop the run; an unexpected
card-handling error is recorded and the loop keeps waiting.

Endpoints: POST /api/ram-batch/start (no card needed; 400/409), GET
/api/ram-batch/status (card-free - the panel polls it during an install),
POST /api/ram-batch/stop (the first _CARD_FREE_POST: it only sets a flag,
so the button works while the worker holds the card lock; a running
install always finishes first) and POST /api/ram-batch/clear (409 while
active).  /api/status carries the ram_batch summary (the detailed state
minus the attempts list), so the panel's headline - the live phase, the
current card, the counts - rides the 2 s poll; the attempts report (the
last 500 kept, the total counted) is fetched only when it changed.  The
batch and a test run exclude each other (409 both ways); the state is in
memory - a server restart ends a run.

PWA: the Batch install (.cap file, per card) RAM operation shares the
install form (ramInstallCapFields(); the single install uses it too and
now sends ram_format, and the block-size validation moved before the
modal), hides the preset selector + Execute and the preset-mismatch
banner, and renders Start/Stop/Clear, the phase headline (equip the next
card / installing card + live step / stopping after the current card),
the counts and the attempts table (newest first).  The panel only works
inside the visible SCP80 RAM pill.

Docs: help EN/RU, api.md (a section + the four table rows), the repo
AGENTS.  Tests: the state/report helpers, the per-card decisions, the
worker loop (a real thread + a fake install), the lifecycle and guards,
the HTTP endpoints (the malformed .cap, the stop under a held card lock,
the test-run refusal) and the PWA panel helpers/wiring.  914 frontend +
919 Python; sw cache simple-v438.
2026-10-14 15:01:56 +03:00

315 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) {
const re = new RegExp('(?:async\\s+)?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 ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams',
'updateStkParamsHex', 'updateInstallParamsHex', 'adfAidFromFci', 'ramQuotaValue', 'ramQuotaTlvs',
'composeRamInstallParams', 'ramInstallParamsCompose',
'parseTlvList', 'parseBerLen']) code += extractFunc(html, fn) + '\n';
eval(code);
const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0',
firstPos: '0', firstId: '00', lastPos: '0', lastId: '00',
channels: '0', msl: '00', tar: '', ad: '', services: '0' };
function vals(over) { return Object.assign({}, base, over); }
// ===== install-parameter coding (TS 102 226 8.2.1.3.2.2.1) =====
test('UICC (EA) parameters match the decrypted live packet', () => {
// The install parameters of the live CAP install (decrypted):
// EA 0F | 80 0D <prio 0 timers 0 textLen 0 menus 0> <channels 1>
// <MSL len 2 / 01 12> <TAR len 3 / AF 4D 01> <services 0>
assert.strictEqual(
stkParamsBuild(vals({ channels: '1', msl: '12', tar: 'AF4D01' })),
'EA0F800D000000000102011203AF4D0100');
});
test('empty TAR emits a zero-length TAR field, never a B00001 default', () => {
// B0 00 01 is the allocated ADF RFM TAR (TS 101 220 Annex D), not an
// applet TAR: the field stays empty and the TAR Value(s) length is 00
// (TS 102 226 8.2.1.3.2.7 - the card may then use a TAR from the AID,
// but only when the AID carries one).
const out = stkParamsBuild(vals({}));
assert.strictEqual(out, 'EA0A80080000000000000000');
assert.ok(!out.includes('B00001'), out);
});
test('SIM (CA) parameters carry the access domain first and no services byte', () => {
assert.strictEqual(stkParamsBuild(vals({ mode: 'ca' })),
'EF0ACA080000000000000000');
assert.strictEqual(stkParamsBuild(vals({ mode: 'ca', ad: '00' })),
'EF0BCA09010000000000000000');
});
test('menu item pairs run first .. last with 0000 fillers between', () => {
assert.strictEqual(stkParamsBuild(vals({ menus: '1', firstId: '7F' })),
'EA0C800A00000001007F00000000');
const out = stkParamsBuild(vals({ menus: '3', firstPos: '1', firstId: '01',
lastPos: '3', lastId: '03' }));
assert.ok(out.includes('010100000303'), out);
});
test('long menu lists use BER long-form lengths', () => {
const out = stkParamsBuild(vals({ menus: '60', firstPos: '1', firstId: '01',
lastPos: '60', lastId: '3C', msl: '16', tar: 'AF4D01' }));
assert.ok(out.startsWith('EA8188808185'), out);
});
test('rejects a TAR that is not a whole number of 3-byte values', () => {
assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D' })), null);
assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D0' })), null);
assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D0102' })), null);
// several TAR values are allowed (3 bytes each)
assert.ok(stkParamsBuild(vals({ tar: 'AF4D01AFFA01' })).includes('06AF4D01AFFA01'));
});
test('rejects menu item identifiers above 7F (reserved for the toolkit framework)', () => {
assert.strictEqual(stkParamsBuild(vals({ menus: '1', firstId: '80' })), null);
assert.strictEqual(stkParamsBuild(vals({ menus: '1', lastId: 'FF' })), null);
});
// ===== form wiring: the STK settings must reach the sent hex =====
test('every toolkit field regenerates the STK parameters hex on edit', () => {
// v3.6.9: the install used to send the hex computed once when the
// checkbox was ticked - edits to the fields afterwards were dropped
// (the live install carried the defaults, not the entered TAR).
const block = html.slice(html.indexOf('id="rc-toolkit-body"'),
html.indexOf('id="ram-install-params-hex"'));
const re = /<(?:input|select)\b[^>]*id="(rc-tk-[^"]+)"[^>]*>/g;
const seen = [];
let m;
while ((m = re.exec(block))) {
seen.push(m[1]);
assert.ok(/on(?:input|change)="[^"]*updateStkParamsHex/.test(m[0]),
m[1] + ' does not refresh the hex: ' + m[0]);
}
assert.strictEqual(seen.length, 17, 'expected 17 toolkit fields, got ' + seen.join(', '));
});
test('the applet TAR field has no B00001 default or placeholder', () => {
const m = /<input id="rc-tk-tar"[^>]*>/.exec(html);
assert.ok(m, 'rc-tk-tar not found');
assert.ok(!/value="B00001"/.test(m[0]), m[0]);
assert.ok(!/placeholder="B00001"/.test(m[0]), m[0]);
assert.ok(/oninput="updateStkParamsHex\(\)"/.test(m[0]), m[0]);
assert.ok(!html.includes("f.tkTar || 'B00001'"), 'the chain toolkit default must stay empty');
});
test('adfAidFromFci extracts the DF name (tag 84) from an FCI', () => {
// the live ADF.USIM FCI (read from the card, 2026-09-28): the DF name is
// 12 bytes - the base A0000000871002 is only a prefix, and a shorter AID
// makes the card reject the access entry with 6A80.
assert.strictEqual(
adfAidFromFci('622882027821840CA0000000871002FF49FF05898A01058B032F0617C60C90016083010183010A830181'),
'A0000000871002FF49FF0589');
// an FCI without a DF name, and garbage
assert.strictEqual(adfAidFromFci('6207820278218A0105'), '');
assert.strictEqual(adfAidFromFci(''), '');
assert.strictEqual(adfAidFromFci('ZZZZ'), '');
});
test('composeRamInstallParams composes C9 + EF (C7/C8) like the vendor scripts', () => {
// The vendor's working install (samples/uicc/applets/STK3):
// C9 22 <config> EF 08 C7 02 0000 C8 02 0000 EA ...
// C9 is mandatory (GP Table 11-49): an empty field is sent as C9 00 -
// without it the card rejects the data field with 6A80 (live 2026-09-28).
assert.strictEqual(composeRamInstallParams('', '', '', '', '').install_params, 'C900');
assert.strictEqual(
composeRamInstallParams('082905112000012066', '', '', '', '').install_params,
'C909082905112000012066');
assert.strictEqual(composeRamInstallParams('', '0', '0', '', '').install_params,
'C900EF08C7020000C8020000');
assert.strictEqual(composeRamInstallParams('', '32768', '', '', '').install_params,
'C900EF06C70400008000');
assert.strictEqual(composeRamInstallParams('AA', '', '1234', '', '').install_params,
'C901AAEF04C80204D2');
// extra raw TLVs follow the generated prefix
assert.strictEqual(composeRamInstallParams('', '', '', 'CB02AABB', '').install_params,
'C900CB02AABB');
// a C9 above 255 bytes uses the 2-byte BER length (0x12C = 300)
const longC9 = 'AA'.repeat(300);
assert.strictEqual(composeRamInstallParams(longC9, '', '', '', '').install_params,
'C982012C' + longC9);
});
test('an EF-form STK part takes the quotas into its own EF (one EF, TS 102 226 8.2.1.3.2.1)', () => {
// Two EFs made the card read the quotas and ignore the CA (live
// 2026-10-05: por_ok but no STK menu): the CA and the quotas share one
// System Specific Parameters EF (GPD_SPE_013 Table 6-5).
const ca = stkParamsBuild(vals({ mode: 'ca' })); // EF0ACA08...
assert.strictEqual(ca, 'EF0ACA080000000000000000');
const c = composeRamInstallParams('', '100', '', '', ca);
assert.strictEqual(c.install_params, 'C900');
assert.strictEqual(c.stk_params, 'EF0EC7020064CA080000000000000000');
assert.strictEqual(c.quota_in_stk, true);
// without quotas the EF passes through unchanged
const c2 = composeRamInstallParams('', '', '', '', ca);
assert.strictEqual(c2.install_params, 'C900');
assert.strictEqual(c2.stk_params, ca);
assert.strictEqual(c2.quota_in_stk, false);
// an EA-form STK part is a sibling of EF: the quotas keep their own EF
const ea = stkParamsBuild(vals({ mode: 'ea' }));
const c3 = composeRamInstallParams('', '100', '', '', ea);
assert.strictEqual(c3.install_params, 'C900EF04C7020064');
assert.strictEqual(c3.stk_params, ea);
assert.strictEqual(c3.quota_in_stk, false);
});
test('the RAM form composes the install parameters from its fields', () => {
const els = fakeForm({ 'rc-c9': 'AA', 'rc-quota-c7': '0' });
assert.deepStrictEqual(ramInstallParamsCompose(),
{ install_params: 'C901AAEF04C7020000', stk_params: '', quota_in_stk: false });
els['rc-quota-c8'].value = '5';
assert.strictEqual(ramInstallParamsCompose().install_params, 'C901AAEF08C7020000C8020005');
// a CA-form STK field takes the quotas into its EF
els['ram-stk-params'].value = 'EF0ACA080000000000000000';
const c = ramInstallParamsCompose();
assert.strictEqual(c.install_params, 'C901AA');
assert.strictEqual(c.stk_params, 'EF12C7020000C8020005CA080000000000000000');
assert.strictEqual(c.quota_in_stk, true);
});
test('updateStkParamsHex names the rejection reason', () => {
globalThis.t = s => s;
const els = fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea',
'rc-tk-tar': 'AF4D' }); // not a multiple of 3 bytes
updateStkParamsHex();
assert.strictEqual(els['ram-stk-params'].value, '');
assert.ok(els['ram-stk-hint'].textContent.includes('STK parameters not generated'),
els['ram-stk-hint'].textContent);
// a valid form clears the hint
els['rc-tk-tar'].value = 'AF4D01';
updateStkParamsHex();
assert.ok(els['ram-stk-params'].value.startsWith('EA'), els['ram-stk-params'].value);
assert.strictEqual(els['ram-stk-hint'].textContent, '');
delete globalThis.t;
});
test('the install body composes the C9/quota prefix', () => {
// v3.28.0: the shared field builder (the single install and the batch)
const fn = extractFunc(html, 'ramInstallCapFields');
assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(fn), fn);
assert.ok(/install_params: composed\.install_params/.test(fn), fn);
assert.ok(/stk_params: composed\.stk_params/.test(fn), fn);
const app = extractFunc(html, 'ramInstallApp');
assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(app), app);
assert.ok(/install_params: composed\.install_params/.test(app), app);
});
test('the ADF AID From-card button is wired', () => {
assert.ok(/id="rc-tk-adfaid-from-card"[^>]*onclick="ramAdfAidFromCard\(\)"/.test(html),
'the ADF AID From-card button is missing');
assert.ok(/async function ramAdfAidFromCard\(\)/.test(html), 'ramAdfAidFromCard is missing');
assert.ok(/pysimFetch\('\/api\/select', \{ name: 'ADF.USIM' \}\)/.test(html),
'the button must read the ADF.USIM FCI');
});
test('the RAM install recomputes the STK hex at send time unless hand-edited', () => {
assert.ok(/oninput="this\.dataset\.manual='1'"/.test(html),
'the hex field must flag manual edits');
assert.ok(/rc-toolkit-enable'\)\.checked && !\(stkEl\.dataset && stkEl\.dataset\.manual\)/.test(html),
'the send-time recompute guard is missing');
});
// ===== form -> hex (the live regression) =====
function fakeForm(values) {
const ids = ['rc-toolkit-enable', 'rc-tk-mode', 'rc-tk-priority', 'rc-tk-timers',
'rc-tk-textlen', 'rc-tk-menus', 'rc-tk-firstpos', 'rc-tk-firstid',
'rc-tk-lastpos', 'rc-tk-lastid', 'rc-tk-channels', 'rc-tk-msl',
'rc-tk-tar', 'rc-tk-ad', 'rc-tk-services', 'rc-tk-fsaccess',
'rc-tk-adfaccess', 'rc-tk-adfaid', 'rc-c9', 'rc-quota-c7',
'rc-quota-c8', 'ram-stk-params', 'ram-stk-hint'];
const checks = ['rc-toolkit-enable', 'rc-tk-fsaccess', 'rc-tk-adfaccess'];
const els = {};
for (const id of ids) els[id] = { value: '', checked: false, dataset: {},
classList: { add() {}, remove() {} } };
for (const [id, v] of Object.entries(values || {})) {
if (checks.indexOf(id) >= 0) els[id].checked = !!v;
else els[id].value = v;
}
globalThis.document = { getElementById: id => els[id] || null };
return els;
}
test('the RAM form fields build the live install parameters end to end', () => {
// The reported regression: the values were entered after the toolkit
// checkbox was ticked and never reached the sent hex - the install
// carried TAR B00001 / MSL 16 / channels 0 and the card answered 6A80.
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1' });
assert.strictEqual(buildRcToolkitParams(), 'EA0F800DFF0000000102011203AF4D0100');
});
test('UICC file-access parameters (82) are appended in EA mode', () => {
// TS 102 226 8.2.1.3.2.2/8.2.1.3.2.2.2: the file-access list is the
// tag '81' field ('82' is the *Administrative* Access field); every entry
// ends with the "Length of Access Domain DAP" byte (00 = no DAP):
// [FS AID len 00 = shared FS][AD len 01][ADP 00 = full][DAP len 00]
// [ADF AID len][ADF AID][AD len 01][ADP 00][DAP len 00]
// The SIM path grants the same rights via the CA Access Domain field; the
// ADF entry is an extension of the file-system entry. Under '82' the card
// rejected the ADF entry with 6A80 (live 2026-09-28).
const base = vals({ channels: '1', msl: '12', tar: 'AF4D01' });
assert.strictEqual(stkParamsBuild(Object.assign({}, base, { fsAccess: true })),
'EA15800D000000000102011203AF4D0100810400010000');
assert.strictEqual(
stkParamsBuild(Object.assign({}, base, { fsAccess: true, adfAccess: true })),
'EA20800D000000000102011203AF4D0100810F0001000007A0000000871002010000');
assert.strictEqual(
stkParamsBuild(Object.assign({}, base, { fsAccess: true, adfAccess: true,
adfAid: 'A0000000871002FF33FFFF89010101' })),
'EA28800D000000000102011203AF4D01008117000100000FA0000000871002FF33FFFF89010101010000');
assert.strictEqual(stkParamsBuild(base), 'EA0F800D000000000102011203AF4D0100');
assert.strictEqual(stkParamsBuild(Object.assign({}, base, { adfAccess: true })),
'EA0F800D000000000102011203AF4D0100');
// the ADF AID must be 5..16 bytes
assert.strictEqual(stkParamsBuild(Object.assign({}, base,
{ fsAccess: true, adfAccess: true, adfAid: 'A00000' })), null);
assert.strictEqual(stkParamsBuild(Object.assign({}, base,
{ fsAccess: true, adfAccess: true, adfAid: 'A0'.repeat(17) })), null);
});
test('the RAM form emits full file access when the checkbox is ticked', () => {
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true });
assert.strictEqual(buildRcToolkitParams(),
'EA15800DFF0000000102011203AF4D0100810400010000');
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true,
'rc-tk-adfaccess': true });
assert.strictEqual(buildRcToolkitParams(),
'EA20800DFF0000000102011203AF4D0100810F0001000007A0000000871002010000');
});
test('updateStkParamsHex refreshes the field and clears the manual flag', () => {
const els = fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea',
'rc-tk-tar': 'AF4D01' });
els['ram-stk-params'].dataset.manual = '1';
updateStkParamsHex();
assert.ok(els['ram-stk-params'].value.startsWith('EA'), els['ram-stk-params'].value);
assert.ok(!els['ram-stk-params'].dataset.manual,
'a regeneration supersedes a manual edit');
});