fix: Explore delete - real SPI2, no-PoR verdict, local removal (v3.6.13)
"Failed: 9000" although the delete executed: the RAM helpers sent `sp-spi2` - the base SPI2 select, whose default is "00 - No PoR" - so the card answered the envelope 9000 with no PoR and the verdict treated the missing PoR as a failure. `cardsApplyFields` also set `sp-spi2-hex` from the preset and then `updateSp()` recomputed it from the selects (which the preset never updated), clobbering the preset byte for anything reading it. - `getRamSpParams()` reads the computed `sp-spi2-hex`; `cardsApplyFields` syncs the SPI2 selects from the preset byte (base = low 5 bits, bit 0x20 = PoR via SMS-SUBMIT, dynamic option for unlisted bases) and the base select gains the missing cipher + RC/CC/DS combinations (15/19/1D). - a missing PoR is no longer a failure (the SPI may request none, and the card sometimes refuses one): `OK` with the SW, or `OK (no PoR)`. - a successful delete drops the object from the Explore result locally (no re-explore): an applet row goes away; a cascade package delete also drops the applets whose AID starts with the package AID. The consumed counter is still saved. - tests: ramRemoveFromExplorer (app/cascade/prefix), the no-PoR flow, the SPI2 source guard, and the updated delete-flow stubs. 641 frontend / 496 Python green; version 3.6.13; sw simple-v286.
This commit is contained in:
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
|
||||
|
||||
// Extract chain builder functions and dependencies
|
||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu',
|
||||
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2',
|
||||
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer',
|
||||
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
|
||||
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute',
|
||||
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
|
||||
@@ -440,6 +440,33 @@ test('ramRemoteSwOk mirrors the server success set', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('ramRemoveFromExplorer drops the object locally (cascade drops its applets)', () => {
|
||||
let renders = 0;
|
||||
globalThis.ramRenderExplorer = () => { renders++; };
|
||||
_ramExplorerData = {
|
||||
apps: [{ aid: 'F0414C4641610101' }, { aid: 'F0414C4641610199' },
|
||||
{ aid: 'A1130001180001FFFFFFFF89A1003908' }],
|
||||
elfs: [{ aid: 'F0414C46416101' }, { aid: 'A1130001180001FFFFFFFF89A1003900' }],
|
||||
};
|
||||
assert.strictEqual(ramRemoveFromExplorer('f0414c4641610101', false), true);
|
||||
assert.deepStrictEqual(_ramExplorerData.apps.map(a => a.aid),
|
||||
['F0414C4641610199', 'A1130001180001FFFFFFFF89A1003908']);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(ramRemoveFromExplorer('F0414C46416101', true), true);
|
||||
assert.deepStrictEqual(_ramExplorerData.elfs.map(e => e.aid),
|
||||
['A1130001180001FFFFFFFF89A1003900']);
|
||||
assert.deepStrictEqual(_ramExplorerData.apps.map(a => a.aid),
|
||||
['A1130001180001FFFFFFFF89A1003908'], 'an unrelated applet stays');
|
||||
assert.strictEqual(renders, 2);
|
||||
assert.strictEqual(ramRemoveFromExplorer('DEADBEEF', false), false);
|
||||
delete globalThis.ramRenderExplorer;
|
||||
});
|
||||
|
||||
test('getRamSpParams reads the computed SPI2 byte, not the base select', () => {
|
||||
const fn = extractFunc(html, 'getRamSpParams');
|
||||
assert.ok(/spi2: document\.getElementById\('sp-spi2-hex'\)/.test(fn), fn);
|
||||
});
|
||||
|
||||
test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () => {
|
||||
// Apps (40) and ELF (20/10) listings can exceed the ENVELOPE response;
|
||||
// with SPI2=0x01 the card answers actual_response_sms_submit and the data
|
||||
@@ -451,7 +478,7 @@ test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () =>
|
||||
function stubDeleteEnv(sendResult) {
|
||||
// ramShowProgress/ramHideProgress are the real extracted helpers
|
||||
const els = fakeRamDocument(['ram-result', 'ram-steps', 'ram-progress', 'ram-progress-text']);
|
||||
const calls = { refresh: [], saved: [], sent: [], explored: null };
|
||||
const calls = { refresh: [], saved: [], sent: [], explored: null, removed: null };
|
||||
globalThis.t = s => s;
|
||||
globalThis.spRefreshFromPreset = sel => { calls.refresh.push(sel); };
|
||||
globalThis.getRamSpParams = () => ({ cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' });
|
||||
@@ -475,10 +502,13 @@ function unstubDeleteEnv() {
|
||||
globalThis.spRefreshFromPreset = () => ''; // the top-level stub
|
||||
}
|
||||
|
||||
test('ramDeleteFromExplorer refreshes the preset and continues from the consumed counter', async () => {
|
||||
test('ramDeleteFromExplorer refreshes the preset and drops the record locally', async () => {
|
||||
const { els, calls } = stubDeleteEnv({ success: true,
|
||||
por: { response_status: 'por_ok', decoded: { last_status_word: '9000' } } });
|
||||
_ramExplorerData = { apps: [{ aid: 'F0414C4641610101' }], elfs: [{ aid: 'F0414C46416101' }] };
|
||||
globalThis.ramRenderExplorer = () => {};
|
||||
await ramDeleteFromExplorer('F0414C46416101', true);
|
||||
delete globalThis.ramRenderExplorer;
|
||||
assert.deepStrictEqual(calls.refresh, ['ram-card-sel'],
|
||||
'the preset must be re-read before the operation');
|
||||
assert.strictEqual(calls.sent.length, 1);
|
||||
@@ -486,8 +516,11 @@ test('ramDeleteFromExplorer refreshes the preset and continues from the consumed
|
||||
assert.strictEqual(calls.sent[0].cntr, '0000000005');
|
||||
assert.deepStrictEqual(calls.saved, ['0000000006'],
|
||||
'the accepted packet advances the saved counter');
|
||||
assert.strictEqual(calls.explored, '0000000006',
|
||||
'the re-explore must start from the consumed counter, never replay it');
|
||||
assert.deepStrictEqual(_ramExplorerData.elfs, [],
|
||||
'a successful cascade delete drops the package from the Explore result');
|
||||
assert.deepStrictEqual(_ramExplorerData.apps, [],
|
||||
'and the package applets (AID prefix) with it');
|
||||
assert.strictEqual(calls.explored, null, 'no re-explore is run');
|
||||
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
||||
unstubDeleteEnv();
|
||||
});
|
||||
|
||||
@@ -22,8 +22,10 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
// The real functions behind the Explore Delete flow: the counter the card
|
||||
// consumed must be persisted into the preset, or the next operation starts
|
||||
// one behind and is rejected with cntr_low.
|
||||
// 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',
|
||||
@@ -36,7 +38,7 @@ function fakeEnv(por, success) {
|
||||
const els = {};
|
||||
const mk = () => ({ value: '', textContent: '',
|
||||
classList: { add() {}, remove() {}, toggle() {} } });
|
||||
for (const id of ['sp-spi1', 'sp-spi2', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
|
||||
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' };
|
||||
@@ -44,74 +46,83 @@ function fakeEnv(por, success) {
|
||||
// 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';
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ name: 'C', cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }];
|
||||
const calls = { saved: 0, explored: null, sent: null };
|
||||
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;
|
||||
// the preset re-read (cardsApply -> the sp-* form fields); the real
|
||||
// spRefreshFromPreset is covered by cards_counter.test.js
|
||||
globalThis.spRefreshFromPreset = () => {
|
||||
els['sp-cntr'].value = cards[0].cntr;
|
||||
return cards[0].cntr;
|
||||
};
|
||||
globalThis.ramSendOta = async (apdu, sp) => {
|
||||
calls.sent = { apdu: apdu, cntr: sp.cntr };
|
||||
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2 };
|
||||
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 re-explores from it', async () => {
|
||||
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, '80E40000094F07F0414C4641610100');
|
||||
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
|
||||
assert.strictEqual(cards[0].cntr, '0000000006',
|
||||
'the preset must carry the counter the card consumed');
|
||||
assert.strictEqual(els['sp-cntr'].value, '0000000006');
|
||||
assert.ok(calls.saved > 0, 'cardsSave() must persist it');
|
||||
assert.strictEqual(calls.explored, '0000000006',
|
||||
'the re-explore must start at N+1, never replay N');
|
||||
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].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].cntr, '0000000005');
|
||||
assert.strictEqual(calls.saved, 0);
|
||||
assert.strictEqual(calls.explored, null);
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
test('a refused DELETE still advances the counter but does not re-explore', async () => {
|
||||
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].cntr, '0000000006');
|
||||
assert.strictEqual(calls.explored, null);
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
test('a delete accepted via actual_response_sms_submit advances and re-explores', async () => {
|
||||
// The card consumed the packet and will deliver the remote result as an
|
||||
// SMS-SUBMIT; the counter must advance and the re-explore must run (the
|
||||
// remote SW is unknown, so no SW is shown).
|
||||
const { els, calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
|
||||
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].cntr, '0000000006');
|
||||
assert.strictEqual(calls.explored, '0000000006');
|
||||
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
||||
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].cntr, '0000000005');
|
||||
assert.strictEqual(calls.explored, null);
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user