v3.22.0 (review fixes): the ownership reconciliation and the import guards

The suite/script ownership invariant could break silently: a suites import
with mode "replace" wiped the suites while their scripts stayed behind
(suite_id pointing at gone suites - invisible in the UI), a scripts import
with mode "replace" left every suite's member list dangling, and a script
whose suite_id disagreed with its listing suite stayed inconsistent.

- server: reconcile_scripts_to_suites() repairs the invariant - a script a
  suite lists adopts that suite (the member list is authoritative), a script
  no suite lists joins "Imported scripts"; migrate_scripts_to_suites()
  delegates to it (the startup migration is the same pass) and the suites
  import runs it after loading, so a "replace" adopts the wiped suites'
  scripts instead of losing them.
- the scripts import refuses mode "replace" while any suite still lists
  scripts (400 naming the dangling references; merge is the normal path).
- nits from the review: a legacy numeric `event` is no longer parsed as hex
  in the step summary/error check (0x0A displayed as 0x10), and an integral
  float memory quota (a JSON client's 200.0) is accepted.
- tests: the reconciliation (missing suite, listed-but-mismatched,
  idempotent), the scripts-replace guard (refused/allowed), the
  suites-replace adoption, the float quota, the numeric-event summary.
  Tests: 838 frontend + 800 Python; sw cache simple-v402.
This commit is contained in:
2026-10-06 01:15:20 +03:00
parent 40c1e0f148
commit b2cb42caf5
8 changed files with 166 additions and 27 deletions
+10 -2
View File
@@ -820,7 +820,10 @@ policy) and `POST /api/test/scripts/copy` (`{id, suite_id, name?}`) clones it
into the target. `POST /api/test/scripts/import` takes into the target. `POST /api/test/scripts/import` takes
`{scripts: [...], mode: "merge"|"replace", suite_id?}` (the old localStorage `{scripts: [...], mode: "merge"|"replace", suite_id?}` (the old localStorage
export shape; invalid entries are reported in `errors` instead of failing the export shape; invalid entries are reported in `errors` instead of failing the
whole import; `suite_id` attaches the imported scripts to that suite). whole import; `suite_id` attaches the imported scripts to that suite). A
`replace` is **refused while any suite still lists scripts** — the wipe would
leave dangling references; delete those scripts first, or import with
`merge`.
### `GET /api/test/suites` and the suite store ### `GET /api/test/suites` and the suite store
@@ -852,7 +855,12 @@ removes it - refused while the suite still holds scripts - and
`{suites: [...], scripts: [...], mode: "merge"|"replace"}`: the scripts are `{suites: [...], scripts: [...], mode: "merge"|"replace"}`: the scripts are
imported first (ids preserved; a collision gets a fresh uuid and the suites' imported first (ids preserved; a collision gets a fresh uuid and the suites'
references are remapped), then the suites; an unresolvable reference is references are remapped), then the suites; an unresolvable reference is
reported in `errors` and the suite is skipped. reported in `errors` and the suite is skipped. After the import the
ownership invariant is reconciled: a script the imported suites list adopts
its listing suite, and a script no suite lists any more — the `replace` mode
wipes the suites that owned them — is **adopted into "Imported scripts"**
instead of becoming invisible (the same reconciliation runs at startup for a
v3.21.0 store).
### `POST /api/test/run` (single script or suite) ### `POST /api/test/run` (single script or suite)
+5 -2
View File
@@ -7197,7 +7197,8 @@ function testStepSummary(step) {
: 'item=' + p.item_id); : 'item=' + p.item_id);
} }
else if (step.kind === 'event') { else if (step.kind === 'event') {
const code = parseInt(p.event, 16); // a legacy numeric event is the code itself; the editor stores hex
const code = (typeof p.event === 'number') ? p.event : parseInt(p.event, 16);
s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?'))); s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?')));
if (p.src) s += ' src=' + p.src; if (p.src) s += ' src=' + p.src;
} }
@@ -8290,7 +8291,9 @@ function testStepFormError() {
if (problem) return problem; if (problem) return problem;
} }
if (step.kind === 'event') { if (step.kind === 'event') {
const code = parseInt(String((step.params || {}).event || ''), 16); const rawEvent = (step.params || {}).event;
const code = (typeof rawEvent === 'number') ? rawEvent
: parseInt(String(rawEvent || ''), 16);
const cfg = EVENT_FORMS[code]; const cfg = EVENT_FORMS[code];
if (!cfg) return t('No form for this event - use the raw Envelope action.'); if (!cfg) return t('No form for this event - use the raw Envelope action.');
const bad = eventFormPatternError(cfg, (step.params || {}).fields || {}); const bad = eventFormPatternError(cfg, (step.params || {}).fields || {});
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v401'; const CACHE = 'simple-v402';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+6 -1
View File
@@ -465,12 +465,17 @@ test('the event step collects the form values, the built data and the source', (
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '00', 'test-f-ev-ti': 'ZZZ', fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '00', 'test-f-ev-ti': 'ZZZ',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' }); 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
assert.match(testStepFormError(), /Invalid/); assert.match(testStepFormError(), /Invalid/);
// the summary names the event and its source // the summary names the event and its source; a legacy numeric event is
// the code itself (the editor stores the hex string form)
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event', assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: '03', fields: {} } }), 'EVENT Location status'); params: { event: '03', fields: {} } }), 'EVENT Location status');
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event', assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: '00', data: '1C0101', src: '83' } }), params: { event: '00', data: '1C0101', src: '83' } }),
'EVENT MT call src=83'); 'EVENT MT call src=83');
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: 10 } }), 'EVENT ' + EVENT_NAMES[10]);
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: '0A' } }), 'EVENT ' + EVENT_NAMES[10]);
}); });
test('the shared event-form helpers render, prefill and collect', () => { test('the shared event-form helpers render, prefill and collect', () => {
+74 -20
View File
@@ -754,9 +754,13 @@ def _single_ef_value(hex_str):
def _quota_tlv(tag, value): def _quota_tlv(tag, value):
"""A C7/C8 memory-quota TLV: the value is a 2-byte integer up to 32767 """A C7/C8 memory-quota TLV: the value is a 2-byte integer up to 32767
and a 4-byte integer above (GP Card Spec 9.7 / Table 11-49; pySim's and a 4-byte integer above (GP Card Spec 9.7 / Table 11-49; pySim's
``StripHeaderAdapter(GreedyBytes, 4, steps=[2,4])``).""" ``StripHeaderAdapter(GreedyBytes, 4, steps=[2,4])``). An integral float
is accepted (a JSON client may send ``200.0``)."""
try: try:
v = int(str(value).strip(), 10) num = float(str(value).strip())
if not num.is_integer():
raise ValueError
v = int(num)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError('%s quota must be an integer in bytes' % tag) raise ValueError('%s quota must be an integer in bytes' % tag)
if not 0 <= v <= 0xFFFFFFFF: if not 0 <= v <= 0xFFFFFFFF:
@@ -5998,21 +6002,49 @@ def _test_script_move(store, suites, cur, target_id):
return new, None, 200 return new, None, 200
def migrate_scripts_to_suites(script_store, suite_store): def reconcile_scripts_to_suites(script_store, suite_store):
"""Attach pre-suite scripts (no ``suite_id``) to an auto-created """Make the ownership invariant hold again: every script belongs to
"Imported scripts" suite: since v3.22.0 a script belongs to exactly one exactly one suite and that suite lists it.
suite, and the one-time migration keeps a v3.21.0 store usable - the
operator renames or regroups them with move/copy. Returns the number of - a script no suite lists (a v3.21.0 store, a suites import with
attached scripts.""" ``mode: "replace"``, a hand edit or an interrupted move) is attached to
the auto-created "Imported scripts" suite;
- a script a suite lists but whose ``suite_id`` is empty or points
elsewhere adopts the listing suite (the member list is authoritative).
Idempotent; returns the number of changed scripts. Called at startup
(the v3.21.0 migration) and after a suites import."""
if script_store is None or suite_store is None: if script_store is None or suite_store is None:
return 0 return 0
orphans = script_store.orphans() suites = suite_store.list()
if not orphans: owner = {}
for s in suites:
for e in s['scripts']:
owner.setdefault(e['script_id'], s['id'])
orphans = []
adopted = 0
for script in script_store.list():
sid = script.get('suite_id') or None
if sid and owner.get(script['id']) == sid:
continue
listed_by = owner.get(script['id'])
if listed_by:
# the member list is authoritative: adopt the listing suite
try:
script_store.update(script['id'], {'suite_id': listed_by})
adopted += 1
except (test_scripts.TestScriptError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot adopt %s: %s\n'
% (script['id'], e))
continue
orphans.append(script)
if not orphans and not adopted:
return 0 return 0
suite = suite_store.find_by_name('Imported scripts') suite = suite_store.find_by_name('Imported scripts')
if suite is None: if suite is None:
suite = suite_store.add({'name': 'Imported scripts'}) suite = suite_store.add({'name': 'Imported scripts'})
entries = list(suite['scripts']) entries = list(suite['scripts'])
known = {e['script_id'] for e in entries}
attached = 0 attached = 0
for s in orphans: for s in orphans:
try: try:
@@ -6021,16 +6053,26 @@ def migrate_scripts_to_suites(script_store, suite_store):
sys.stderr.write('TESTSUITES: cannot attach %s to "%s": %s\n' sys.stderr.write('TESTSUITES: cannot attach %s to "%s": %s\n'
% (s['id'], suite['name'], e)) % (s['id'], suite['name'], e))
continue continue
if s['id'] not in known:
entries.append({'script_id': s['id'], 'role': 'member', 'on_fail': 'stop'}) entries.append({'script_id': s['id'], 'role': 'member', 'on_fail': 'stop'})
known.add(s['id'])
attached += 1 attached += 1
if attached: if attached:
try: try:
suite_store.update(suite['id'], {'scripts': entries}) suite_store.update(suite['id'], {'scripts': entries})
except (test_suites.TestSuiteError, OSError) as e: except (test_suites.TestSuiteError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot fill "%s": %s\n' % (suite['name'], e)) sys.stderr.write('TESTSUITES: cannot fill "%s": %s\n' % (suite['name'], e))
sys.stderr.write('TESTSUITES: attached %d script(s) to "%s"\n' if attached or adopted:
% (attached, suite['name'])) sys.stderr.write('TESTSUITES: reconciled %d script(s) (attached to "%s": %d, '
return attached 'adopted by their suite: %d)\n'
% (attached + adopted, suite['name'], attached, adopted))
return attached + adopted
def migrate_scripts_to_suites(script_store, suite_store):
"""The v3.21.0 -> v3.22.0 migration: attach the pre-suite scripts to the
"Imported scripts" suite (the general reconciliation, called at startup)."""
return reconcile_scripts_to_suites(script_store, suite_store)
def _test_request_blocked(path): def _test_request_blocked(path):
@@ -9795,13 +9837,11 @@ class PysimHandler(BaseHTTPRequestHandler):
except OSError as e: except OSError as e:
self._test_suites_store_error(e) self._test_suites_store_error(e)
return return
# The imported scripts carry the *old* suite id: point them at the # The ownership invariant: the imported scripts carry the *old*
# suite that now owns them. # suite id, and a `replace` wiped the suites that owned the rest -
for suite in store.list(): # the reconciliation re-points the listed scripts and adopts the
for e in suite['scripts']: # newly unreferenced ones into "Imported scripts".
cur = scripts.get(e['script_id']) reconcile_scripts_to_suites(scripts, store)
if cur is not None and cur.get('suite_id') != suite['id']:
scripts.update(e['script_id'], {'suite_id': suite['id']})
resp['scripts'] = {'added': script_result.get('added', 0), resp['scripts'] = {'added': script_result.get('added', 0),
'skipped': script_result.get('skipped', 0)} 'skipped': script_result.get('skipped', 0)}
resp['skipped'] = resp.get('skipped', 0) + len(ref_errors) resp['skipped'] = resp.get('skipped', 0) + len(ref_errors)
@@ -9825,6 +9865,20 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp(resp) self._log_resp(resp)
return return
mode = body.get('mode') if body.get('mode') in ('merge', 'replace') else 'merge' mode = body.get('mode') if body.get('mode') in ('merge', 'replace') else 'merge'
if mode == 'replace':
# A wipe would leave every suite's member list dangling: the
# scripts are the references' targets. Merge is the normal
# path; delete the suites' scripts first if a replace is
# really wanted.
holders = [s for s in suites.list() if s['scripts']]
if holders:
resp = {'error': 'replace would leave %d suite(s) with '
'dangling script references - delete their '
'scripts first, or import with merge'
% len(holders)}
self._send_json(resp, 400)
self._log_resp(resp)
return
target = suites.get(body.get('suite_id')) if body.get('suite_id') else None target = suites.get(body.get('suite_id')) if body.get('suite_id') else None
if body.get('suite_id') and target is None: if body.get('suite_id') and target is None:
resp = {'error': 'unknown target suite'} resp = {'error': 'unknown target suite'}
+39
View File
@@ -1382,6 +1382,45 @@ class TestScriptStoreHttpTests(unittest.TestCase):
self.assertEqual(resp['added'], 0) self.assertEqual(resp['added'], 0)
self.assertIn('unknown script', ' '.join(resp.get('errors') or [])) self.assertIn('unknown script', ' '.join(resp.get('errors') or []))
def test_scripts_import_replace_is_refused_while_suites_hold_scripts(self):
suite = self._suite()
status, resp = self._post('/api/test/scripts', self._script(suite_id=suite['id']))
self.assertEqual(status, 200, resp)
status, resp = self._post('/api/test/scripts/import', {
'mode': 'replace',
'scripts': [{'name': 'new', 'steps': [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1}}]}]})
self.assertEqual(status, 400, resp)
self.assertIn('dangling script references', resp['error'])
self.assertEqual(len(self.store.list()), 1) # nothing was wiped
# with no suite holding scripts a replace is allowed
status, resp = self._post('/api/test/scripts/delete',
{'id': self.store.list()[0]['id']})
self.assertEqual(status, 200, resp)
status, resp = self._post('/api/test/scripts/import', {
'mode': 'replace',
'scripts': [{'name': 'new', 'steps': [
{'type': 'action', 'kind': 'status', 'params': {'attempts': 1}}]}]})
self.assertEqual(status, 200, resp)
self.assertEqual([x['name'] for x in self.store.list()], ['new'])
def test_suites_import_replace_adopts_the_wiped_suites_scripts(self):
# a suites replace wipes the suite that owned a script: the script is
# not lost - the reconciliation adopts it into "Imported scripts"
suite = self._suite(name='old')
status, resp = self._post('/api/test/scripts', self._script(suite_id=suite['id']))
self.assertEqual(status, 200, resp)
sid = resp['script']['id']
status, resp = self._post('/api/test/suites/import', {
'mode': 'replace', 'suites': [{'name': 'new', 'scripts': []}]})
self.assertEqual(status, 200, resp)
imported = self.suites.find_by_name('Imported scripts')
self.assertIsNotNone(imported)
self.assertEqual(self.store.get(sid)['suite_id'], imported['id'])
self.assertEqual([e['script_id'] for e in imported['scripts']], [sid])
self.assertEqual(sorted(s['name'] for s in self.suites.list()),
['Imported scripts', 'new'])
def test_import_accepts_the_old_localstorage_export(self): def test_import_accepts_the_old_localstorage_export(self):
status, resp = self._post('/api/test/scripts/import', {'scripts': [ status, resp = self._post('/api/test/scripts/import', {'scripts': [
{'name': 'A', 'steps': [{'type': 'action', 'kind': 'status', {'name': 'A', 'steps': [{'type': 'action', 'kind': 'status',
+3
View File
@@ -1586,6 +1586,9 @@ class CapApduSequenceTest(unittest.TestCase):
# a 4-byte quota above 32767 (GP Card Spec 9.7) # a 4-byte quota above 32767 (GP Card Spec 9.7)
self.assertEqual(_compose_install_params('', '', volatile_quota=32768), self.assertEqual(_compose_install_params('', '', volatile_quota=32768),
'C900EF06C70400008000') 'C900EF06C70400008000')
# an integral float (a JSON client may send 200.0) is accepted
self.assertEqual(_compose_install_params('', '', nv_quota=200.0),
'C900EF04C80200C8')
# an EA-form STK part is a sibling of EF (the reference TCA form) # an EA-form STK part is a sibling of EF (the reference TCA form)
self.assertEqual(_compose_install_params('', 'EA0480000000', self.assertEqual(_compose_install_params('', 'EA0480000000',
volatile_quota=100), volatile_quota=100),
+27
View File
@@ -222,6 +222,33 @@ class MigrationTests(unittest.TestCase):
# idempotent: nothing left to attach # idempotent: nothing left to attach
self.assertEqual(srv.migrate_scripts_to_suites(scripts, suites), 0) self.assertEqual(srv.migrate_scripts_to_suites(scripts, suites), 0)
def test_the_reconciliation_adopts_listed_and_missing_suite_scripts(self):
# The general invariant repair: a script whose suite_id points at a
# missing suite (a suites import with mode replace) joins "Imported
# scripts"; a script a suite lists but whose suite_id is empty or
# wrong adopts the listing suite (the member list is authoritative).
from pysim_simple_server import server as srv
from pysim_simple_server import test_scripts
with tempfile.TemporaryDirectory() as tmp:
scripts = test_scripts.TestScriptStore(pathlib.Path(tmp) / 's.json')
suites = test_suites.TestSuiteStore(pathlib.Path(tmp) / 'u.json')
step = {'type': 'action', 'kind': 'status', 'params': {'attempts': 1}}
suite_a = suites.add({'name': 'A'})
gone = 'f' * 32
lost = scripts.add({'name': 'lost', 'suite_id': gone, 'steps': [step]})
listed = scripts.add({'name': 'listed', 'steps': [step]})
suites.update(suite_a['id'], {'scripts': [
{'script_id': listed['id'], 'role': 'setup', 'on_fail': 'stop'}]})
self.assertEqual(srv.reconcile_scripts_to_suites(scripts, suites), 2)
# the listing suite wins (its role is kept)
self.assertEqual(scripts.get(listed['id'])['suite_id'], suite_a['id'])
# the lost one is adopted by "Imported scripts"
imported = suites.find_by_name('Imported scripts')
self.assertEqual(scripts.get(lost['id'])['suite_id'], imported['id'])
self.assertEqual([e['script_id'] for e in imported['scripts']], [lost['id']])
# idempotent
self.assertEqual(srv.reconcile_scripts_to_suites(scripts, suites), 0)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()