85cc4b9535
The counter an operation consumed was re-entered by the PWA on every send (the form field was filled from the preset and posted back), so a stale tab could send a value the card rejects (cntr_low), and typing in the field silently wrote the preset. Now the store is the single source of truth and the form field is a per-send override (Option B). - _preset_counter_seed: an explicit `cntr` in the request wins (a hand send, the counter probe or a pre-built packet may deliberately pick one); otherwise the preset store's value for the packet's keyset. Wired into /api/send-ota (the APDU path; the pre-built `sp` path keeps the caller's bookkeeping value - the packet carries its own counter), /api/ram-install, /api/ram-install-app, /api/cap-compat, /api/tar-probe and /api/sp-verify (the reference must use the counter the JS packet was built with). - the send-ota response echoes the effective `cntr` (a caller that omitted one learns what was used) and the OTA SEND log line prints it. - persistence is unchanged and monotonic (presets.set_counter refuses backwards writes), pinned by tests: an explicit lower counter is used for the packet but never lowers the store, a higher accepted one is saved. - PWA: the Secured packet counter field starts empty with the preset's next value as its placeholder (auto: <cntr>); typing affects that send only and is never written to the preset (spCntrSyncPreset is gone), spParams / getRamSpParams omit an empty counter, the RAM/send flows sync only the local preset copy (forward-only) instead of filling the field, the +/- buttons start from the effective value, and the preview/verify packet is built from the effective counter (genSp). Placeholder translated EN/RU. - the runner and /api/counter-probe keep their existing seeding (the probe already starts at the failed value and walks above it). - docs: api.md's optional-`cntr` contract + seeding notes on every endpoint, help EN/RU 3.1. Tests: 822 frontend + 734 Python; sw cache simple-v393.
146 lines
6.4 KiB
JavaScript
146 lines
6.4 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',
|
|
'escHtml', 'esc',
|
|
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
|
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
|
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
|
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
|
|
'ramKeysetGuard',
|
|
]) {
|
|
code += extractFunc(html, fn) + '\n';
|
|
}
|
|
code = 'var _cardsKeysetCount = 0;\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);
|
|
});
|