Files
simple/frontend/tests/ram_counter_flow.test.js
T
catarrh cb40662e4e v3.26.0: per-TAR default keysets (the preset/SCP80/RAM rework)
Every preset TAR entry carries `keyset` (int 1-15) - the keyset every
operation addressed to that TAR uses: mandatory for any non-zero MSL
(the counters are keyset-linked), optional for MSL 00 (allowed: some
cards reject the no-security 00/00 KIc/KID form).  The store fills it
automatically when the preset has a single keyset, the validation
refuses an entry naming an undefined keyset, and the schema is 6 (a v5
file loads leniently; editing it forces the fix).

The keyset selectors are gone: the SCP80 TAR field became a select of
the preset's entries (applying the TAR's keyset to the form and seeding
the SPI1 from its MSL); the RAM operations use the preset's ISD role
entry (the PWA sends only the preset id, the ISD TAR, SPI and the
counter override - the server resolves the keyset and the keys:
presets.tar_keyset, _preset_tar_keyset, _resolve_operation_keyset,
_op_keyset, used by send-ota's APDU path, ram-install, ram-install-app
and cap-compat); the counter probe and the test runner's scp80 steps
default to their TAR's keyset (an explicit kvn stays an override).  A
security TAR without a keyset refuses the operation ("set it in the
Cards tab") - never a silent keyless send - and the start buttons wait
for a usable operation-TAR keyset.

The TAR probe checks the preset's TAR list only (the standard
TAR_PROBE_TARS list and the custom-TAR localStorage are gone; a `tars`
list selects a subset, a TAR outside the preset is refused), each row
using its own MSL and default keyset.  The Cards editor's TAR table
gained a Keyset column (the only keyset preselected, an amber marker
when a security MSL lacks one).

Docs: help EN/RU, api.md's tar-probe contract, both AGENTS.  Tests: the
store (the field, the auto-fill, the schema, the string forms), the
server resolution (send-ota + the refusal, the probe's per-row keysets
and refusals), the PWA (the TAR select, the RAM ISD resolution, the
keyset-free requests) - several fixtures updated for the new schema.
891 frontend + 885 Python; sw cache simple-v434.
2026-10-14 12:21:16 +03:00

153 lines
7.0 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);
}
// The real functions behind the Explore Delete flow: the counter the card
// consumed must be persisted into the preset, a successful delete drops the
// record locally (no re-explore), and a delete with no PoR (the SPI may
// request none) is not a failure - live 2026-09-28 showed "Failed: 9000"
// although the delete executed.
let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead',
'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'ramCntrLowBox', 'ramCntrLowClear',
'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsRoleTar', 'cardsTarEntry', 'cardsTarList', 'cardsRoleDefault', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'ramPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n';
}
code = 'var _cardsKeysetCount = 0;\n' + code;
// the role defaults the TAR helpers read (v3.26.0)
for (const c of ['CARDS_TAR_DEFAULTS', 'CARDS_TAR_ROLES', 'CARDS_MSL_DEFAULT']) {
const m = new RegExp('const\\s+' + c + '\\s*=\\s*([^;]+);').exec(html);
if (!m) throw new Error('const ' + c + ' not found');
code = 'var ' + c + ' = ' + m[1] + ';\n' + code;
}
eval(code);
function fakeEnv(por, success) {
const els = {};
const mk = () => ({ value: '', textContent: '',
classList: { add() {}, remove() {}, toggle() {} } });
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps',
'ram-progress', 'ram-progress-text']) els[id] = mk();
els['ram-card-sel'] = { value: '0' };
// the preset keys the real spRefreshFromPreset/cardsApply would copy into
// the form (the delete flow aborts without them)
els['sp-kic-key'].value = 'AA';
els['sp-kid-key'].value = 'BB';
els['sp-spi2-hex'].value = '01';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ id: 'preset-1', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000005' }] }];
const calls = { saved: 0, explored: null, removed: null, sent: null };
globalThis.cardsSave = () => { calls.saved++; };
globalThis.cardsRender = () => {};
globalThis.ramRender = () => {};
globalThis.t = s => s;
globalThis.alert = () => {};
globalThis.confirm = () => true;
globalThis.spRefreshFromPreset = () => {
const cntr = cards[0].keysets[0].cntr;
els['sp-cntr'].value = cntr;
return cntr;
};
globalThis.ramSendOta = async (apdu, sp) => {
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id };
return { success: success !== false, por: por };
};
globalThis.ramExplore = async sp => { calls.explored = sp.cntr; };
globalThis.ramRemoveFromExplorer = (aid, cascade) => {
calls.removed = { aid: aid, cascade: cascade };
return true;
};
return { els, calls };
}
test('accepted delete persists the consumed counter and drops the record', async () => {
const { els, calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '9000' } });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(calls.sent.cntr, '0000000005');
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101');
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
'the preset must carry the counter the card consumed');
// the form field is an override (empty = auto, the server seeds it); the
// operation only syncs the local preset copy, never the field (v3.20.0)
assert.strictEqual(els['sp-cntr'].value, '0000000005');
assert.strictEqual(calls.sent.preset_id, 'preset-1',
'the operation must name the server-side preset that persists the counter');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
assert.strictEqual(calls.explored, null, 'no re-explore: the record is dropped locally');
assert.strictEqual(els['ram-result'].textContent, 'OK');
});
test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
const { els, calls } = fakeEnv(undefined);
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
assert.strictEqual(els['ram-result'].textContent, 'OK');
assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
});
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
const { calls } = fakeEnv({ response_status: 'cntr_low' });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
assert.strictEqual(calls.saved, 0);
assert.strictEqual(calls.removed, null);
});
test('a refused DELETE still advances the counter but drops nothing', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '6A88' } });
await ramDeleteFromExplorer('F0414C46416101', true);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.strictEqual(calls.removed, null);
});
test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
});
test('a send failure leaves the preset untouched', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
assert.strictEqual(calls.removed, null);
});