Files
simple/tests/test_presets.py
T
catarrh 212bfc24ac v3.27.0: the outfile import
Import many card presets at once from a card vendor's plain-text dump.
The new pure pysim_simple_server/outfile.py (parse_outfile) plus
POST /api/presets/import-outfile: without `mode` the call is a dry-run
preview returning the parsed presets and a report (nothing is stored),
with `merge`/`replace` the same parse runs and the presets go through
the preset store.

The parser: the data starts at the first line whose whitespace tokens
all look like column names and include ICCID (a leading `word:` prefix
is stripped, so a `var_out:IMSI ICCID ...` header line matches while a
config line naming a value - `ICCID:897...` or `ICCID 897...` - cannot);
one row per card follows.  Recognised columns (case-insensitive): ICCID,
PIN1/PIN2, PUK1/PUK2, ADM (the ASCII string, hex-encoded), PSK (32 hex;
an optional PSKID/PSK_ID names the identity, defaulting to the card's
ICCID) and KICn/KIDn - one keyset per numbered pair, up to 15;
IMSI*/EKI/KIKn are ignored.  The KIc/KID bytes are inferred: the keyset
number is the column number, the algorithm nibble follows the key size
(8 B DES, 16 B 3DES2 or AES - the preview's choice, 3DES2 default,
24 B 3DES3; anything else skips the keyset with a note), both bytes
carrying the same number (TS 102 225 A.2).  The TARs never come from the
file: the caller's table (default the three role entries) is applied
with one global default keyset written into every entry (the card's
first keyset when unset).  An invalid optional code is dropped with a
report line instead of failing the card; the report names the columns,
the rows read, the parsed keysets and every skip.

The ICCID normaliser fix (both sides, drift-guarded): the trailing F
(or Fs) is the odd-nibble padding - when the rest is digits only it is
the plain digit form (the vendors print the ICCID as digits+F),
otherwise the value is the raw nibble-swapped EF hex; a raw value never
ends in F, so the rule is unambiguous.

PWA: the Import outfile button in the Cards list, the hidden file input
and the preview panel (the common name, the 16-byte algorithm select,
the TAR table with the role defaults editable and + TAR / remove for
extras, the global default keyset select over the parsed keysets, the
merge/replace mode, the parsed cards table, the report, Refresh
preview, Import).  The panel joins refreshDynamicI18n for the language
switch.

Docs: help EN/RU (the term outfile stays untranslated in RU), api.md's
new section, the repo AGENTS.  Tests: the parser (both fixture shapes,
the column detection, the key-size inference, the options/TAR table,
the PSK identity, the skips, the store round-trip), the endpoint (the
preview, the import, the duplicate skip, the 400s), the normaliser on
both sides, the PWA helpers/wiring and the algorithm16 drift guard
against outfile.py.  904 frontend + 903 Python; sw cache simple-v436.
2026-10-14 13:37:27 +03:00

615 lines
30 KiB
Python

"""Card preset store tests (server-side presets).
The store replaces the localStorage presets; the ICCID normalisation must stay
byte-compatible with the PWA's `cardsNormIccid` (digits / spaced / raw
nibble-swapped EF hex), every keyset carries its own monotonic counter (one per
keyset number, TS 102 225 Annex A.1), and the file must survive a corrupt store
without silently destroying it.
"""
import json
import pathlib
import tempfile
import unittest
from pysim_simple_server import presets
DIGITS = '8970119000004600098'
RAW_HEX = '980711090000640090F8' # the PWA's fixture for DIGITS
def make_store(tmp):
return presets.PresetStore(pathlib.Path(tmp) / 'card_presets.json')
def keyset(**kw):
ks = {'kic': '15', 'kid': '15', 'kicKey': 'AA', 'kidKey': 'BB'}
ks.update(kw)
return ks
# The three role entries with their default keyset (v3.26.0) - the base for
# the multi-keyset fixtures, where no auto-fill happens.
ROLE_TARS = [{'role': role, 'tar': tar, 'msl': '16', 'keyset': 1}
for role, tar in (('isd', '000000'), ('uiccRfm', 'B00000'),
('usimRfm', 'B00001'))]
def preset(**kw):
p = {'name': 'C', 'keysets': [keyset()]}
if 'tars' not in kw:
# v3.26.0: the TARs carry their default keyset - the fixtures name
# keyset 1, so a multi-keyset preset still validates (a single-keyset
# one would be auto-filled anyway).
p['tars'] = [dict(t) for t in ROLE_TARS]
p.update(kw)
return p
class IccidNormalisationTests(unittest.TestCase):
def test_digits_spaces_and_raw_hex_normalise_alike(self):
self.assertEqual(presets.normalize_iccid(DIGITS), DIGITS)
self.assertEqual(presets.normalize_iccid(' 89 70 1190-0000 4600 098 '), DIGITS)
self.assertEqual(presets.normalize_iccid(RAW_HEX), DIGITS)
self.assertEqual(presets.normalize_iccid(RAW_HEX.lower()), DIGITS)
def test_empty_values_mean_no_iccid(self):
for value in ('', None, ' ', '---'):
self.assertEqual(presets.normalize_iccid(value), '')
def test_leading_zeros_are_stripped(self):
self.assertEqual(presets.normalize_iccid('008970119000004600098'),
'8970119000004600098')
def test_odd_length_raw_hex_gets_the_js_fallback_nibble(self):
# swapNibbles pads the missing nibble with 'f', then trailing Fs go
self.assertEqual(presets.normalize_iccid('980711090000640090F8'), DIGITS)
self.assertEqual(presets._swap_nibbles('123'), '21F3')
def test_the_outfile_digits_f_form_normalises(self):
# the vendors' outfiles print the ICCID as digits + an odd-nibble F
# pad (v3.27.0) - not the raw EF form (which never ends in F)
self.assertEqual(presets.normalize_iccid(DIGITS + 'F'), DIGITS)
self.assertEqual(presets.normalize_iccid(DIGITS + 'FF'), DIGITS)
self.assertEqual(presets.normalize_iccid(DIGITS.lower() + 'f'), DIGITS)
self.assertEqual(presets.normalize_iccid('FFFF'), '')
class CounterTests(unittest.TestCase):
def test_counter_hex_is_the_fixed_width_protocol_form(self):
self.assertEqual(presets.counter_hex('1'), '0000000001')
self.assertEqual(presets.counter_hex('abc'), '0000000ABC')
self.assertEqual(presets.counter_hex('FFFFFFFFFF'), 'FFFFFFFFFF')
def test_counter_ahead_is_forward_only_with_wrap(self):
self.assertTrue(presets.counter_ahead('0000000001', '0000000002'))
self.assertFalse(presets.counter_ahead('0000000002', '0000000001'))
self.assertFalse(presets.counter_ahead('0000000002', '0000000002'))
# the 40-bit field wrapped past the top
self.assertTrue(presets.counter_ahead('FFFFFFFFFF', '0000000000'))
self.assertFalse(presets.counter_ahead('nonsense', '0000000002'))
class KeysetKvnTests(unittest.TestCase):
def test_the_key_version_is_the_high_nibble_of_kic(self):
self.assertEqual(presets.keyset_kvn(keyset(kic='15')), 1)
self.assertEqual(presets.keyset_kvn(keyset(kic='29')), 2)
self.assertEqual(presets.keyset_kvn(keyset(kic='3A')), 3)
self.assertIsNone(presets.keyset_kvn(keyset(kic='')))
self.assertIsNone(presets.keyset_kvn(keyset(kic='5')))
self.assertIsNone(presets.keyset_kvn(None))
class StoreCrudTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = make_store(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def test_add_applies_the_form_defaults_and_assigns_an_id(self):
p = self.store.add(preset(iccid=DIGITS))
self.assertTrue(p['id'])
self.assertEqual([t['role'] for t in p['tars']],
['isd', 'uiccRfm', 'usimRfm'])
self.assertEqual([t['tar'] for t in p['tars']],
['000000', 'B00000', 'B00001'])
self.assertEqual([t['msl'] for t in p['tars']], ['16', '16', '16'])
self.assertNotIn('spi1', p) # the card-wide SPI1 is gone: per-TAR MSL
self.assertNotIn('spi2', p)
self.assertEqual(p['keysets'][0]['cntr'], '0000000001')
self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 1)
self.assertEqual([x['id'] for x in self.store.list()], [p['id']])
def test_a_preset_carries_extra_tars_with_optional_descriptions(self):
p = self.store.add(preset(tars=[
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': '0A', 'desc': 'My applet'},
{'tar': '', 'msl': '', 'desc': ''}, # a blank editor row
]))
self.assertEqual([(t.get('role', ''), t['tar'], t['msl']) for t in p['tars']],
[('isd', 'AF4D01', '1A'), ('uiccRfm', 'B00000', '16'),
('usimRfm', 'B00001', '16'), ('', 'AF4D02', '0A')])
self.assertEqual(p['tars'][3]['desc'], 'My applet')
self.assertNotIn('role', p['tars'][3]) # free rows carry no role
self.assertEqual(len(p['tars']), 4) # the blank row was dropped
def test_every_tar_needs_its_msl_and_a_unique_tar(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': ''},
]))
self.assertIn('MSL', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '000000', 'msl': '16'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': '000000', 'msl': '16'},
]))
self.assertIn('duplicate TAR', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(tars=[
{'role': 'isd', 'tar': '0000', 'msl': '16'}]))
self.assertIn('three hex bytes', str(ctx.exception))
def test_tar_lookup_helpers(self):
p = self.store.add(preset(tars=[
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'},
{'tar': 'AF4D02', 'msl': '0A', 'desc': 'My applet'},
]))
self.assertEqual(presets.role_tar(p, 'isd'), 'AF4D01')
self.assertEqual(presets.role_tar(p, 'usimRfm'), 'B00001')
self.assertEqual(presets.role_tar(p, 'nope'), '')
self.assertEqual(presets.tar_msl(p, 'af4d01'), '1A')
self.assertEqual(presets.tar_msl(p, 'AF4D02'), '0A')
self.assertEqual(presets.tar_msl(p, '123456'), '')
self.assertIsNone(presets.tar_entry(p, ''))
self.assertEqual(presets.tar_entry(p, 'AF4D02')['desc'], 'My applet')
def test_update_replaces_the_tar_table(self):
p = self.store.add(preset())
upd = self.store.update(p['id'], {'tars': [
{'role': 'isd', 'tar': 'AF4D01', 'msl': '1A'},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'role': 'usimRfm', 'tar': 'B00001', 'msl': '16'}]})
self.assertEqual(presets.role_tar(upd, 'isd'), 'AF4D01')
self.assertEqual(presets.tar_msl(upd, 'AF4D01'), '1A')
# a malformed tars value is refused, not silently ignored or reset
with self.assertRaises(presets.PresetError):
self.store.update(p['id'], {'tars': 'nonsense'})
self.assertEqual(presets.role_tar(self.store.get(p['id']), 'isd'), 'AF4D01')
def test_a_preset_carries_several_keysets(self):
p = self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
keyset(kic='29', kid='29', cntr='0000000005')]))
self.assertEqual([presets.keyset_kvn(ks) for ks in p['keysets']], [1, 2])
self.assertEqual(self.store.find_keyset(p['id'], 2)['cntr'], '0000000005')
self.assertIsNone(self.store.find_keyset(p['id'], 3))
def test_a_tar_keyset_is_mandatory_for_a_nonzero_msl(self):
# v3.26.0: the counters are keyset-linked, so any non-zero MSL needs a
# default keyset; MSL 00 may omit it (the packet is built keyless) and
# may still carry one (some cards reject the '00/00' KIc/KID form)
two = [keyset(), keyset(kic='29', kid='29')]
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=two, tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'desc': 'push'}]))
self.assertIn('needs a default keyset', str(ctx.exception))
p = self.store.add(preset(keysets=two, tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '00', 'desc': 'push'}]))
self.assertIsNone(p['tars'][3]['keyset'])
p = self.store.add(preset(keysets=two, tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '00', 'keyset': 1}]))
self.assertEqual(p['tars'][3]['keyset'], 1)
def test_a_tar_keyset_must_exist_in_the_preset(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'keyset': 3}]))
self.assertIn('does not define', str(ctx.exception))
def test_a_single_keyset_fills_the_tar_defaults(self):
# v3.26.0: one keyset in the preset sets every TAR's default keyset
p = self.store.add(preset(tars=[{'tar': 'B00011', 'msl': '16'},
{'tar': 'B00012', 'msl': '00'}]))
self.assertTrue(all(t['keyset'] == 1 for t in p['tars']))
# several keysets leave an MSL 00 entry alone...
p = self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '00'}]))
self.assertIsNone(p['tars'][3]['keyset'])
# ...and a non-zero MSL must name one explicitly
p = self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'keyset': 2}]))
self.assertEqual(p['tars'][3]['keyset'], 2)
def test_tar_keyset_string_forms_and_the_helper(self):
p = self.store.add(preset(keysets=[keyset(), keyset(kic='29', kid='29')],
tars=ROLE_TARS + [
{'tar': 'B00011', 'msl': '16', 'keyset': '02'},
{'tar': 'B00012', 'msl': '16', 'keyset': '2'}]))
self.assertEqual([t['keyset'] for t in p['tars']], [1, 1, 1, 2, 2])
self.assertEqual(presets.tar_keyset(p, 'B00011'), 2)
self.assertIsNone(presets.tar_keyset(p, 'B00099'))
def test_validation_requires_at_least_one_keyset(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[]))
self.assertIn('keyset', str(ctx.exception))
def test_a_keyset_needs_both_keys(self):
with self.assertRaises(presets.PresetError):
self.store.add(preset(keysets=[keyset(kicKey='')]))
with self.assertRaises(presets.PresetError):
self.store.add(preset(keysets=[keyset(kidKey='')]))
def test_kic_and_kid_must_carry_the_same_key_version(self):
# TS 102 225 A.2: a mismatch is rejected by the card - refuse it here
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(kic='15', kid='25')]))
self.assertIn('same keyset number', str(ctx.exception))
def test_key_version_00_and_duplicates_are_refused(self):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(kic='05', kid='05')]))
self.assertIn('01-0F', str(ctx.exception))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(keysets=[keyset(kic='15', kid='15'),
keyset(kic='15', kid='15')]))
self.assertIn('duplicate keyset number', str(ctx.exception))
# the byte must be a full hex byte
with self.assertRaises(presets.PresetError):
self.store.add(preset(keysets=[keyset(kic='1', kid='1')]))
def test_validation_requires_the_psk_rules(self):
with self.assertRaises(presets.PresetError):
self.store.add(preset(pskIdentity='id'))
with self.assertRaises(presets.PresetError):
self.store.add(preset(pskIdentity='id', pskKey='00'))
with self.assertRaises(presets.PresetError):
self.store.add(preset(name=''))
def test_pin_and_puk_codes_are_optional_and_normalised(self):
p = self.store.add(preset())
# absent/empty: all four default to ''
self.assertEqual([p[k] for k in ('pin1', 'puk1', 'pin2', 'puk2')],
['', '', '', ''])
p = self.store.add(preset(name='Codes', pin1=' 12 34 ', puk1='12345678',
pin2='0000', puk2=' 8765 4321 '))
self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['puk1'], '12345678')
self.assertEqual(p['pin2'], '0000')
self.assertEqual(p['puk2'], '87654321')
def test_pin_and_puk_codes_must_be_4_to_8_digits(self):
for field in ('pin1', 'puk1', 'pin2', 'puk2'):
for bad in ('123', '123456789', '12A4', 'abcd'):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(name='X', **{field: bad}))
self.assertIn(field.upper(), str(ctx.exception))
# 4 and 8 digits are the accepted bounds; empty stays optional
p = self.store.add(preset(name='Bounds', pin1='1234', puk1='12345678'))
self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['puk1'], '12345678')
def test_release_is_optional_and_must_be_4_to_18(self):
p = self.store.add(preset())
self.assertEqual(p['release'], '')
p = self.store.add(preset(name='Rel', release='14'))
self.assertEqual(p['release'], '14')
# numbers arrive as strings from the form and as ints from the API
p = self.store.add(preset(name='Int', release=4))
self.assertEqual(p['release'], '4')
p = self.store.add(preset(name='Top', release=' 18 '))
self.assertEqual(p['release'], '18')
for bad in ('3', '19', '0', '20', 'abc', '1x', '012'):
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(name='X', release=bad))
self.assertIn('release', str(ctx.exception))
def test_duplicate_iccid_is_refused_across_stored_forms(self):
self.store.add(preset(iccid=DIGITS))
with self.assertRaises(presets.PresetError) as ctx:
self.store.add(preset(name='Other', iccid=RAW_HEX))
self.assertIn('already exists', str(ctx.exception))
# an empty ICCID is optional and never a duplicate
self.store.add(preset(name='No ICCID'))
self.store.add(preset(name='No ICCID 2'))
self.assertEqual(len(self.store.list()), 3)
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_update_is_partial_and_keeps_the_id(self):
p = self.store.add(preset(iccid=DIGITS))
up = self.store.update(p['id'], {'name': 'Renamed'})
self.assertEqual(up['id'], p['id'])
self.assertEqual(up['name'], 'Renamed')
self.assertEqual(up['keysets'][0]['kic'], '15')
# replacing the keysets with another number must re-point the TARs'
# default keysets in the same update (the Cards-form edit path)
with self.assertRaises(presets.PresetError) as ctx:
self.store.update(p['id'], {'keysets': [keyset(kic='29', kid='29',
cntr='0000000009')]})
self.assertIn('does not define', str(ctx.exception))
tars = [dict(t, keyset=2) for t in up['tars']]
up = self.store.update(p['id'], {'keysets': [keyset(kic='29', kid='29',
cntr='0000000009')],
'tars': tars})
self.assertEqual(presets.keyset_kvn(up['keysets'][0]), 2)
self.assertEqual(up['keysets'][0]['cntr'], '0000000009')
self.assertTrue(all(t['keyset'] == 2 for t in up['tars']))
self.assertIsNone(self.store.update('nope', {'name': 'x'}))
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(keysets=[keyset(cntr=bad)]))
self.assertIn('counter', str(ctx.exception))
# whitespace is cleaned and a short value is stored fixed-width
p = self.store.add(preset(keysets=[keyset(cntr='12 34')]))
self.assertEqual(p['keysets'][0]['cntr'], '0000001234')
p = self.store.add(preset(keysets=[keyset(cntr='abc')]))
self.assertEqual(p['keysets'][0]['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'], {'keysets': [keyset(cntr='ZZ')]})
self.assertEqual(self.store.get(p['id'])['keysets'][0]['cntr'], '0000000001')
def test_remove(self):
p = self.store.add(preset())
self.assertTrue(self.store.remove(p['id']))
self.assertFalse(self.store.remove(p['id']))
self.assertEqual(self.store.list(), [])
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'])
self.assertEqual(self.store.find_by_iccid(' 89 70 1190-0000 4600 098 ')['id'], p['id'])
self.assertIsNone(self.store.find_by_iccid('1234567890123456789'))
self.assertIsNone(self.store.find_by_iccid(''))
def test_callers_get_copies(self):
p = self.store.add(preset())
p['name'] = 'mutated'
p['keysets'][0]['cntr'] = 'mutated'
self.assertEqual(self.store.get(p['id'])['name'], 'C')
self.assertEqual(self.store.get(p['id'])['keysets'][0]['cntr'], '0000000001')
items = self.store.list()
items[0]['name'] = 'mutated'
self.assertEqual(self.store.list()[0]['name'], 'C')
class StoreCounterTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = make_store(self.tmp.name)
self.p = self.store.add(preset(iccid=DIGITS))
def tearDown(self):
self.tmp.cleanup()
def counter(self, kvn=1):
return self.store.find_keyset(self.p['id'], kvn)['cntr']
def test_set_counter_advances_and_ignores_stale_values(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=1)
self.assertEqual(self.counter(), '0000000005')
# a stale caller can never regress the counter
self.store.set_counter(self.p['id'], '0000000003', 'send-ota', kvn=1)
self.assertEqual(self.counter(), '0000000005')
# equal values are a no-op
self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=1)
self.assertEqual(self.counter(), '0000000005')
def test_each_key_version_has_its_own_counter(self):
# TS 102 225 A.1: a dedicated counter per keyset
p = self.store.update(self.p['id'], {'keysets': [
keyset(kic='15', kid='15', cntr='0000000002'),
keyset(kic='29', kid='29', cntr='0000000007')]})
self.store.set_counter(p['id'], '0000000003', 'send-ota', kvn=1)
self.assertEqual(self.counter(1), '0000000003')
self.assertEqual(self.counter(2), '0000000007') # untouched
self.store.set_counter(p['id'], '0000000008', 'send-ota', kvn=2)
self.assertEqual(self.counter(1), '0000000003')
self.assertEqual(self.counter(2), '0000000008')
def test_an_unknown_key_version_changes_nothing(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=3)
self.assertEqual(self.counter(1), '0000000001')
def test_kvn_none_uses_the_only_keyset(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota')
self.assertEqual(self.counter(1), '0000000005')
# ... and is refused (logged) when several keysets exist
p = self.store.update(self.p['id'], {'keysets': [
keyset(kic='15', kid='15'), keyset(kic='29', kid='29')]})
self.store.set_counter(p['id'], '0000000009', 'send-ota')
self.assertEqual(self.counter(1), '0000000001')
def test_set_counter_allows_a_wrapped_value(self):
self.store.update(self.p['id'], {'keysets': [keyset(cntr='FFFFFFFFFF')]})
self.store.set_counter(self.p['id'], '0000000000', 'send-ota', kvn=1)
self.assertEqual(self.counter(), '0000000000')
def test_set_counter_on_unknown_id_is_none(self):
self.assertIsNone(self.store.set_counter('nope', '0000000002', kvn=1))
def test_set_counter_ignores_a_malformed_value(self):
for bad in ('ZZ', '00000000000000000000', ''):
self.store.set_counter(self.p['id'], bad, 'send-ota', kvn=1)
self.assertEqual(self.counter(), '0000000001')
def test_counter_changes_are_audited_with_the_key_version(self):
self.store.set_counter(self.p['id'], '0000000005', 'send-ota', kvn=1)
lines = self.store.audit_path.read_text(encoding='utf-8').strip().splitlines()
self.assertEqual(len(lines), 1)
entry = json.loads(lines[0])
self.assertEqual(entry['old'], '0000000001')
self.assertEqual(entry['new'], '0000000005')
self.assertEqual(entry['kvn'], 1)
self.assertEqual(entry['source'], 'send-ota')
class StorePersistenceTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_the_file_round_trips(self):
store = make_store(self.tmp.name)
p = store.add(preset(iccid=DIGITS, pskIdentity='id', pskKey='00' * 16,
pin1='1234', puk1='12345678',
keysets=[keyset(kic='15', kid='15'),
keyset(kic='29', kid='29', cntr='0000000004')]))
raw = json.loads(pathlib.Path(store.path).read_text(encoding='utf-8'))
self.assertEqual(raw['version'], presets.SCHEMA_VERSION)
self.assertEqual(raw['presets'][0]['keysets'][1]['cntr'], '0000000004')
self.assertEqual(raw['presets'][0]['pin1'], '1234')
self.assertEqual(raw['presets'][0]['puk1'], '12345678')
again = make_store(self.tmp.name)
self.assertEqual([x['id'] for x in again.list()], [p['id']])
self.assertEqual(again.find_keyset(p['id'], 2)['cntr'], '0000000004')
def test_a_v380_flat_preset_loads_as_one_keyset(self):
path = pathlib.Path(self.tmp.name) / 'card_presets.json'
path.write_text(json.dumps({'version': 1, 'presets': [
{'name': 'Old', 'iccid': DIGITS, 'kic': '29', 'kid': '29',
'kicKey': '11', 'kidKey': '22', 'cntr': '0000000007',
'spi1': '16', 'spi2': '01'}]}), encoding='utf-8')
store = make_store(self.tmp.name)
p = store.list()[0]
self.assertEqual(len(p['keysets']), 1)
self.assertEqual(presets.keyset_kvn(p['keysets'][0]), 2)
self.assertEqual(p['keysets'][0]['cntr'], '0000000007')
self.assertEqual(p['keysets'][0]['kicKey'], '11')
self.assertNotIn('cntr', p)
# the card-wide spi1 becomes the MSL of all three role entries
self.assertEqual([t['msl'] for t in p['tars']], ['16', '16', '16'])
def test_a_v391_flat_tars_preset_converts_to_the_tar_table(self):
path = pathlib.Path(self.tmp.name) / 'card_presets.json'
path.write_text(json.dumps({'version': 2, 'presets': [
{'name': 'Old', 'tar': 'AF4D01', 'uiccTar': 'B00000',
'usimTar': 'B00001', 'spi1': '1A', 'spi2': '21',
'keysets': [keyset()]}]}), encoding='utf-8')
store = make_store(self.tmp.name)
p = store.list()[0]
self.assertEqual([t['tar'] for t in p['tars']],
['AF4D01', 'B00000', 'B00001'])
self.assertEqual([t['msl'] for t in p['tars']], ['1A', '1A', '1A'])
self.assertNotIn('spi2', p)
def test_no_temporary_files_are_left_behind(self):
store = make_store(self.tmp.name)
p = store.add(preset())
store.add(preset(name='Second'))
store.set_counter(p['id'], '0000000005', 'send-ota', kvn=1)
leftovers = [f.name for f in pathlib.Path(self.tmp.name).iterdir()
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):
path = pathlib.Path(self.tmp.name) / 'card_presets.json'
path.write_text('{not json', encoding='utf-8')
store = make_store(self.tmp.name)
self.assertEqual(store.list(), [])
kept = [f.name for f in pathlib.Path(self.tmp.name).iterdir()
if '.bad-' in f.name]
self.assertEqual(len(kept), 1)
self.assertEqual(pathlib.Path(self.tmp.name, kept[0]).read_text(encoding='utf-8'),
'{not json')
def test_a_missing_file_is_an_empty_store(self):
self.assertEqual(make_store(self.tmp.name).list(), [])
class StoreImportTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = make_store(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def test_import_accepts_the_old_localstorage_shape(self):
# an export from the browser (v3.8.0 or the localStorage era): flat
# key fields, no ids, no defaults filled in
old = [{'name': 'A', 'iccid': DIGITS, 'kic': '15', 'kid': '15',
'kicKey': 'AA', 'kidKey': 'BB', 'cntr': '0000000007'},
{'name': 'B', 'iccid': '', 'kic': '25', 'kid': '25',
'kicKey': 'CC', 'kidKey': 'DD'}]
res = self.store.import_presets(old)
self.assertEqual(res['added'], 2)
self.assertEqual(res['skipped'], 0)
a = self.store.find_by_iccid(DIGITS)
self.assertEqual(a['keysets'][0]['cntr'], '0000000007')
self.assertEqual(presets.keyset_kvn(a['keysets'][0]), 1)
self.assertTrue(a['id'])
def test_import_takes_the_keyset_shape_too(self):
res = self.store.import_presets([preset(iccid=DIGITS)])
self.assertEqual(res['added'], 1)
self.assertEqual(self.store.find_by_iccid(DIGITS)['keysets'][0]['kic'], '15')
def test_import_skips_duplicates_and_reports_invalid_entries(self):
self.store.add(preset(iccid=DIGITS))
res = self.store.import_presets([
preset(iccid=RAW_HEX), # same card in raw form
{'name': 'Broken'}, # no keyset at all
'not a dict',
])
self.assertEqual(res['added'], 0)
self.assertEqual(res['skipped'], 3)
self.assertTrue(any('already exists' in e for e in res['errors']))
self.assertEqual(len(self.store.list()), 1)
def test_import_replace_wipes_the_store(self):
self.store.add(preset(iccid=DIGITS))
res = self.store.import_presets([preset(name='Fresh', iccid='')], mode='replace')
self.assertEqual(res['added'], 1)
self.assertEqual([p['name'] for p in self.store.list()], ['Fresh'])
def test_an_existing_id_is_never_overwritten(self):
p = self.store.add(preset(iccid=DIGITS))
res = self.store.import_presets([dict(preset(name='Copy'), id=p['id'], iccid='')])
self.assertEqual(res['added'], 1)
ids = [x['id'] for x in self.store.list()]
self.assertEqual(len(set(ids)), 2)
class StoreInfoTests(unittest.TestCase):
def test_info_reports_the_path_and_count(self):
with tempfile.TemporaryDirectory() as tmp:
store = make_store(tmp)
store.add(preset())
info = store.info()
self.assertEqual(info['count'], 1)
self.assertEqual(info['path'], str(store.path))
self.assertEqual(info['version'], presets.SCHEMA_VERSION)
def test_default_path_is_the_home_dot_directory(self):
self.assertEqual(presets.default_path().name, 'card_presets.json')
self.assertEqual(presets.default_path().parent.name, '.pysim-simple-server')
if __name__ == '__main__':
unittest.main()