fix: v3.8.0 preset-store review findings

- /api/send-ota no longer 500s on a counter-less body: the plain `sp` path
  reports final_cntr only when the counter is usable (_counter_valid, 1-10 hex
  digits) - before, the new plain-path report ran int('') and the request died
  in the generic handler.  The failure path is guarded the same way.
- _decode_por wraps the keyset/SPI construction in the same "never raise" rule
  as the decode: a malformed counter or missing keys return None instead of
  escaping the handler.
- presets.add() never accepts a taken id (a fresh uuid is assigned, like the
  import path), the counter is validated as 1-10 hex digits instead of being
  silently masked/kept, and set_counter() ignores a malformed value with a log
  line instead of writing it.
- the preset endpoints answer a JSON 500 when the store write fails (read-only
  home, disk full) instead of dropping the connection.
- tests: counter-less send-ota, store-write 500, duplicate id, counter
  validation (add/update/set_counter), and the temp-file check no longer
  matches the audit file; docs/api.md documents final_cntr for the plain path
  and preset_id.
This commit is contained in:
2026-09-30 14:42:54 +03:00
parent 795a1fea49
commit 4ab133c266
5 changed files with 129 additions and 9 deletions
+32 -2
View File
@@ -117,6 +117,27 @@ class StoreCrudTests(unittest.TestCase):
self.assertFalse(self.store.remove(p['id']))
self.assertEqual(self.store.list(), [])
def test_a_caller_supplied_id_never_collides(self):
first = self.store.add(preset())
second = self.store.add(dict(preset(name='B'), id=first['id']))
self.assertNotEqual(second['id'], first['id'])
self.assertEqual(len({p['id'] for p in self.store.list()}), 2)
def test_the_counter_must_be_hex_and_at_most_ten_digits(self):
for bad in ('ZZ', '00000000000000000000'):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(cntr=bad))
self.assertIn('counter', str(ctx.exception))
# whitespace is cleaned and a short value is stored fixed-width
self.assertEqual(self.store.add(preset(cntr='12 34'))['cntr'], '0000001234')
self.assertEqual(self.store.add(preset(cntr='abc'))['cntr'], '0000000ABC')
def test_update_validates_the_counter_too(self):
p = self.store.add(preset())
with self.assertRaises(presets.PresetError):
self.store.update(p['id'], {'cntr': 'ZZ'})
self.assertEqual(self.store.get(p['id'])['cntr'], '0000000001')
def test_find_by_iccid(self):
p = self.store.add(preset(iccid=DIGITS))
self.assertEqual(self.store.find_by_iccid(RAW_HEX)['id'], p['id'])
@@ -160,6 +181,14 @@ class StoreCounterTests(unittest.TestCase):
def test_set_counter_on_unknown_id_is_none(self):
self.assertIsNone(self.store.set_counter('nope', '0000000002'))
def test_set_counter_ignores_a_malformed_value(self):
self.store.set_counter(self.p['id'], 'ZZ', 'send-ota')
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000001')
self.store.set_counter(self.p['id'], '00000000000000000000', 'send-ota')
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000001')
self.store.set_counter(self.p['id'], '', 'send-ota')
self.assertEqual(self.store.get(self.p['id'])['cntr'], '0000000001')
def test_counter_changes_are_audited(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota')
lines = self.store.audit_path.read_text(encoding='utf-8').strip().splitlines()
@@ -189,10 +218,11 @@ class StorePersistenceTests(unittest.TestCase):
def test_no_temporary_files_are_left_behind(self):
store = make_store(self.tmp.name)
store.add(preset())
p = store.add(preset())
store.add(preset(name='Second'))
store.set_counter(p['id'], '0000000005', 'send-ota') # writes the audit file
leftovers = [f.name for f in pathlib.Path(self.tmp.name).iterdir()
if '.json.' in f.name]
if f.name not in ('card_presets.json', 'card_presets.json.audit.jsonl')]
self.assertEqual(leftovers, [])
def test_a_corrupt_store_is_kept_and_the_store_starts_empty(self):