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
+39
View File
@@ -1382,6 +1382,45 @@ class TestScriptStoreHttpTests(unittest.TestCase):
self.assertEqual(resp['added'], 0)
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):
status, resp = self._post('/api/test/scripts/import', {'scripts': [
{'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)
self.assertEqual(_compose_install_params('', '', volatile_quota=32768),
'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)
self.assertEqual(_compose_install_params('', 'EA0480000000',
volatile_quota=100),
+27
View File
@@ -222,6 +222,33 @@ class MigrationTests(unittest.TestCase):
# idempotent: nothing left to attach
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__':
unittest.main()