Files
simple/tests/test_outfile.py
T
catarrh cdd3820064
release / release (push) Waiting to run
v3.32.1: "Start counter value" in the outfile import
The import created presets with the store's default counter (1).  Cards
whose counters are already past it had to be edited preset by preset
afterwards.

- The outfile preview form gains a "Start counter value" field (default
  1, the store's value).  The value is written into every created keyset
  of every imported card - parse_outfile's `start_counter`, 1-10 hex
  digits, whitespace stripped and uppercased; a bad value raises and the
  endpoint answers 400.  Without it the store's default still applies
  (the field is omitted), so the API keeps its old behaviour.
- The preview shows the counter; an import stores the protocol's
  10-digit form (counter_hex).
- Docs: api.md (the field), help EN/RU (the form).
- Version strings + the PWA cache bumped together (simple-v459).

Tests: the parser (every keyset, normalisation, unset, the bad values),
the endpoint (the preview's keysets, the 400, the stored padded form),
the PWA (the field's default, the controls' normalisation, both bodies).
1008 Python + 953 frontend green.
2026-10-21 13:20:28 +03:00

365 lines
18 KiB
Python

"""Outfile import tests (v3.27.0).
The vendors' outfiles carry real key material, so the fixtures here are
**synthetic** files shaped like the two observed layouts: the ALFA style
(a ``var_out:`` column line, three keysets, no codes) and the BAP style (a
long banner/configuration header, the PIN/PUK/ADM columns and the EKI/KIK
columns that must be ignored).
"""
import pathlib
import tempfile
import unittest
from pysim_simple_server import outfile, presets
DIGITS = '8970119000004600098'
DIGITS_F = DIGITS + 'F' # the vendors' odd-nibble F padding
# 16-byte synthetic keys (3DES2 by default)
K16A = '00112233445566778899AABBCCDDEEFF'
K16B = 'FFEEDDCCBBAA99887766554433221100'
K16C = '0123456789ABCDEFFEDCBA9876543210'
def alfa_text():
return '\n'.join([
'var_out:IMSI ICCID KIC1 KID1 KIK1 KIC2 KID2 KIK2',
'250197700000000 ' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' ' + K16A +
' ' + K16C + ' ' + K16B + ' ' + K16A,
'250197700000001 8970119000004600106F ' + K16B + ' ' + K16A + ' ' + K16B +
' ' + K16A + ' ' + K16C + ' ' + K16B,
]) + '\n'
def bap_text():
return '\n'.join([
'********************************',
'* HEADER DESCRIPTION *',
'********************************',
'Customer: Synthetic',
'Batch: 0002',
'TransportKey: 004',
'ICCID:897011900000460005',
'',
'*******************************',
'* OUTPUT VARIABLES *',
'*******************************',
'IMSI1 IMSI2 ICCID EKI PUK1 PUK2 PIN1 PIN2 ADM KIC1 KID1 KIK1',
'250199800390005 FFFFFFFFFFFFFFF ' + DIGITS_F + ' ' + K16A +
' 12345678 87654321 1234 4321 3632333239463337 ' +
K16A + ' ' + K16B + ' ' + K16C,
]) + '\n'
class ColumnDetectionTests(unittest.TestCase):
def test_find_columns_skips_the_header(self):
cols, i = outfile.find_columns(alfa_text())
self.assertEqual(i, 0)
self.assertEqual(cols[:2], ['IMSI', 'ICCID'])
cols, i = outfile.find_columns(bap_text())
self.assertEqual(cols[0], 'IMSI1')
self.assertEqual(cols.index('ICCID'), 2)
# the config line ICCID:897... is a single token and never matches:
# the column line sits after it (line 6 in the fixture)
config_line = bap_text().splitlines().index('ICCID:897011900000460005')
self.assertGreater(i, config_line)
def test_a_file_without_the_column_line_reports_it(self):
res = outfile.parse_outfile('just a banner\nno columns here\n')
self.assertEqual(res['presets'], [])
self.assertIn('column-name line', res['report']['error'])
def test_a_config_line_naming_a_value_is_not_the_column_line(self):
# 'ICCID <value>' names a value, not the column header: the value
# token is not a column name, so the real column line is found
text = ('ICCID 897011900000460005\n'
'ICCID KIC1 KID1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + '\n')
cols, i = outfile.find_columns(text)
self.assertEqual(i, 1)
self.assertEqual(cols, ['ICCID', 'KIC1', 'KID1'])
res = outfile.parse_outfile(text)
self.assertEqual(res['report']['cards'], 1)
self.assertEqual(res['presets'][0]['iccid'], DIGITS)
def test_a_header_without_a_usable_column_is_not_the_column_line(self):
# a line carrying ICCID and nothing the import can consume is not the
# header (every card needs a KICn/KIDn pair)
text = ('ICCID CUSTOMER_NAME\nICCID KIC1 KID1\n' + DIGITS_F + ' ' +
K16A + ' ' + K16B + '\n')
cols, i = outfile.find_columns(text)
self.assertEqual(i, 1)
self.assertEqual(cols, ['ICCID', 'KIC1', 'KID1'])
# ...while a long unknown column name next to a real one is fine
text = ('ICCID TRANSPORT_KEY_IDENTIFIER KIC1 KID1\n' + DIGITS_F + ' ' +
'AB ' + K16A + ' ' + K16B + '\n')
cols, i = outfile.find_columns(text)
self.assertEqual(i, 0)
self.assertEqual(len(cols), 4)
class AlfaShapeTests(unittest.TestCase):
def test_the_rows_become_presets_with_three_keysets(self):
res = outfile.parse_outfile(alfa_text())
self.assertEqual(res['report']['cards'], 2)
self.assertEqual(res['report']['rows'], 2)
self.assertEqual(res['report']['keysets'], ['KIC1/KID1', 'KIC2/KID2'])
p = res['presets'][0]
self.assertEqual(p['iccid'], DIGITS)
self.assertEqual(p['name'], DIGITS) # no name given: the ICCID
self.assertEqual([ks['kic'] for ks in p['keysets']], ['15', '25'])
self.assertEqual([ks['kid'] for ks in p['keysets']], ['15', '25'])
self.assertEqual(p['keysets'][0]['kicKey'], K16A)
self.assertEqual(p['keysets'][1]['kicKey'], K16C)
# the TARs are the three role defaults, all on the card's first keyset
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.assertTrue(all(t['keyset'] == 1 for t in p['tars']))
self.assertNotIn('pskKey', p)
self.assertNotIn('adm', p)
def test_the_parsed_presets_validate_through_the_store(self):
res = outfile.parse_outfile(alfa_text())
with tempfile.TemporaryDirectory() as tmp:
store = presets.PresetStore(pathlib.Path(tmp) / 'card_presets.json')
for entry in res['presets']:
p = store.add(entry)
self.assertEqual(p['iccid'], entry['iccid'])
class BapShapeTests(unittest.TestCase):
def test_the_codes_and_the_adm_are_read(self):
res = outfile.parse_outfile(bap_text())
self.assertEqual(res['report']['cards'], 1)
p = res['presets'][0]
self.assertEqual(p['iccid'], DIGITS)
self.assertEqual(p['pin1'], '1234')
self.assertEqual(p['pin2'], '4321')
self.assertEqual(p['puk1'], '12345678')
self.assertEqual(p['puk2'], '87654321')
self.assertEqual(p['adm'], '3632333239463337') # the key's hex value as-is
# EKI, KIK and the IMSI columns are ignored - one keyset only
self.assertEqual(len(p['keysets']), 1)
self.assertEqual(p['keysets'][0]['kicKey'], K16A)
self.assertNotIn(K16C, [p['keysets'][0]['kicKey'], p['keysets'][0]['kidKey']])
class KeyInferenceTests(unittest.TestCase):
def _one(self, kic_key, algorithm16='3des2', kic='KIC1', kid='KID1'):
text = ('ICCID ' + kic + ' ' + kid + '\n' +
DIGITS_F + ' ' + kic_key + ' ' + kic_key + '\n')
return outfile.parse_outfile(text, algorithm16=algorithm16)
def test_key_size_picks_the_algorithm_nibble(self):
# 8 bytes -> DES (1), 24 -> 3DES3 (9), 16 -> 3DES2 (5) or AES (2)
p = self._one('0011223344556677')['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '11')
p = self._one('00112233445566778899AABBCCDDEEFF0011223344556677')['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '19')
p = self._one(K16A)['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '15')
p = self._one(K16A, algorithm16='aes')['presets'][0]
self.assertEqual(p['keysets'][0]['kic'], '12')
def test_an_unsupported_key_size_skips_the_keyset(self):
res = self._one('00112233445566778899') # 10 bytes
self.assertEqual(res['presets'], [])
self.assertTrue(any('unsupported key size' in s for s in res['report']['skipped']))
def test_the_keyset_number_is_the_column_number(self):
text = ('ICCID KIC1 KID1 KIC2 KID2\n' + DIGITS_F + ' ' +
K16A + ' ' + K16B + ' ' + K16C + ' ' + K16A + '\n')
p = outfile.parse_outfile(text)['presets'][0]
self.assertEqual([ks['kic'] for ks in p['keysets']], ['15', '25'])
def test_a_keyset_missing_one_side_is_skipped(self):
text = 'ICCID KIC1 KID1 KIC2\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' ' + K16C + '\n'
res = outfile.parse_outfile(text)
self.assertEqual(len(res['presets'][0]['keysets']), 1)
class OptionTests(unittest.TestCase):
def test_the_common_name_applies_to_every_card(self):
res = outfile.parse_outfile(alfa_text(), name='Alfa DSA 0002')
self.assertEqual([p['name'] for p in res['presets']],
['Alfa DSA 0002', 'Alfa DSA 0002'])
def test_the_tar_table_and_the_global_keyset(self):
tars = outfile.default_tars() + [{'tar': 'B00011', 'msl': '0A', 'desc': 'push'}]
res = outfile.parse_outfile(alfa_text(), tars=tars, tar_keyset=2)
p = res['presets'][0]
self.assertEqual(len(p['tars']), 4)
self.assertEqual(p['tars'][3]['tar'], 'B00011')
self.assertTrue(all(t['keyset'] == 2 for t in p['tars']))
self.assertEqual(res['report']['tars'], 4)
def test_the_per_row_keysets_win(self):
"""A TAR entry's own keyset (chosen in the preview) is used; the global
default fills the entries without one, then the card's first keyset
(v3.29.1)."""
text = ('ICCID KIC1 KID1 KIC2 KID2\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' ' + K16C + ' ' + K16A + '\n')
tars = [{'role': 'isd', 'tar': '000000', 'msl': '16', 'keyset': 2},
{'role': 'uiccRfm', 'tar': 'B00000', 'msl': '16'},
{'tar': 'B00011', 'msl': '1A', 'desc': 'push', 'keyset': 1}]
res = outfile.parse_outfile(text, tars=tars)
got = [(t['tar'], t['keyset']) for t in res['presets'][0]['tars']]
self.assertEqual(got[0], ('000000', 2)) # its own keyset
self.assertEqual(got[-1], ('B00011', 1)) # its own keyset
self.assertEqual(got[1], ('B00000', 1)) # the card's first keyset
# the global default fills the entries without one; an entry's own
# keyset still wins over it
res = outfile.parse_outfile(text, tars=tars, tar_keyset=2)
self.assertEqual([t['keyset'] for t in res['presets'][0]['tars']], [2, 2, 1])
# an unusable keyset is the caller's error
with self.assertRaises(ValueError):
outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 99}])
with self.assertRaises(ValueError):
outfile.parse_outfile(text, tars=[{'tar': '000000', 'msl': '16', 'keyset': 'x'}])
def test_the_start_counter_is_written_into_every_keyset(self):
"""The import form's "Start counter value": every created keyset
carries it (the store pads it to the protocol's 10-digit form); without
it the store's default ('1') applies. An unusable value is the
caller's error, never a silent default (v3.32.1)."""
res = outfile.parse_outfile(alfa_text(), start_counter='1a')
ks = res['presets'][0]['keysets']
self.assertGreaterEqual(len(ks), 2)
self.assertEqual([k.get('cntr') for k in ks], ['1A'] * len(ks))
# normalised: whitespace stripped, uppercase
res = outfile.parse_outfile(alfa_text(), start_counter=' a1 ')
self.assertEqual(res['presets'][0]['keysets'][0]['cntr'], 'A1')
# unset: no field at all - the store fills its default
res = outfile.parse_outfile(alfa_text())
self.assertNotIn('cntr', res['presets'][0]['keysets'][0])
# an unusable value refuses the whole parse
for bad in ('XYZ', '1G', '1234567890A', ' '):
with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), start_counter=bad)
def test_a_malformed_tar_table_raises(self):
with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), tars=[{'tar': 'ZZ', 'msl': '16'}])
with self.assertRaises(ValueError):
outfile.parse_outfile(alfa_text(), tars=[
{'tar': '000000', 'msl': '16'}, {'tar': '000000', 'msl': '16'}])
def test_the_psk_column_sets_the_key_and_defaults_the_identity(self):
psk = 'AB' * 16
text = ('ICCID KIC1 KID1 PSK\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' ' + psk + '\n')
p = outfile.parse_outfile(text)['presets'][0]
self.assertEqual(p['pskKey'], psk)
self.assertEqual(p['pskIdentity'], DIGITS)
# an explicit PSKID column wins
text = ('ICCID PSKID KIC1 KID1 PSK\n' + DIGITS_F + ' 89390100000129506903 ' +
K16A + ' ' + K16B + ' ' + psk + '\n')
p = outfile.parse_outfile(text)['presets'][0]
self.assertEqual(p['pskIdentity'], '89390100000129506903')
# a wrong length is dropped with a report line
res = outfile.parse_outfile('ICCID KIC1 KID1 PSK\n' + DIGITS_F + ' ' +
K16A + ' ' + K16B + ' AB\n')
self.assertNotIn('pskKey', res['presets'][0])
self.assertTrue(any('PSK is not 16 bytes' in s for s in res['report']['skipped']))
def test_a_row_with_a_missing_or_extra_value_is_a_file_error(self):
"""The file is positional: an empty value drops its token, so a token
count other than the header's column count means an empty (or extra)
value - the whole file is refused (v3.29.2)."""
# a missing trailing value
text = ('ICCID KIC1 KID1\n' + DIGITS_F + ' ' + K16A + '\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'], [])
self.assertIn('line 2 has 2 values, the header declares 3 columns',
res['report']['error'])
# ...a missing middle value (which used to shift the columns silently)
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + '\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'], [])
self.assertIn('the file is invalid', res['report']['error'])
# ...and an extra value
text = ('ICCID KIC1 KID1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' EXTRA\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'], [])
self.assertIn('4 values', res['report']['error'])
# a declared keyset without a usable value is a file error too
text = ('ICCID KIC1 KID1 KIC2 KID2\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' - ' + K16C + '\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'], [])
self.assertIn('keyset 2 has no usable KIC/KID value', res['report']['error'])
# a valid file keeps parsing (an exact count everywhere)
text = ('ICCID KIC1 KID1 PIN1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 1234 3632333239463337\n')
res = outfile.parse_outfile(text)
self.assertEqual(len(res['presets']), 1)
self.assertEqual(res['presets'][0]['pin1'], '1234')
self.assertEqual(res['presets'][0]['adm'], '3632333239463337')
def test_bad_optional_codes_are_reported_per_row(self):
# the ADM column is the key's hex value, stored as-is (v3.28.2): the
# length varies, and the value is never ASCII-decoded
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 3632333239463337\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'][0]['adm'], '3632333239463337')
# ...an 8-byte binary-looking value (the AM0027 sample's shape)
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' CFA3DD9C6B7F0F8C\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'][0]['adm'], 'CFA3DD9C6B7F0F8C')
# ...a shorter key (4 hex digits) and the lowercase form
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 1a2b3c4d\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'][0]['adm'], '1A2B3C4D')
# a non-hex or too-short ADM is dropped with its reason
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' ZZZZ\n')
res = outfile.parse_outfile(text)
self.assertNotIn('adm', res['presets'][0])
self.assertTrue(any('ADM is not 4-16 hex digits' in s
for s in res['report']['skipped']))
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 123\n')
res = outfile.parse_outfile(text)
self.assertNotIn('adm', res['presets'][0])
self.assertTrue(any('ADM is not 4-16 hex digits' in s
for s in res['report']['skipped']))
# an odd length is a half byte: /api/verify-adm would always refuse the
# stored value, so it is dropped at import time (v3.28.3)
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' 12345\n')
res = outfile.parse_outfile(text)
self.assertNotIn('adm', res['presets'][0])
self.assertTrue(any('ADM is not 4-16 hex digits in byte pairs' in s
for s in res['report']['skipped']))
# ...and so is a value over the 16-digit limit (the verifier pads to 8
# CHV bytes, a longer value would overflow)
text = ('ICCID KIC1 KID1 ADM\n' + DIGITS_F + ' ' + K16A + ' ' + K16B +
' ' + 'AB' * 9 + '\n')
res = outfile.parse_outfile(text)
self.assertNotIn('adm', res['presets'][0])
self.assertTrue(any('ADM is not 4-16 hex digits in byte pairs' in s
for s in res['report']['skipped']))
# a present but invalid PIN is dropped, the card kept
text = ('ICCID KIC1 KID1 PIN1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' 12\n')
res = outfile.parse_outfile(text)
self.assertEqual(len(res['presets']), 1)
self.assertNotIn('pin1', res['presets'][0])
self.assertTrue(any('PIN1' in s for s in res['report']['skipped']))
# a present but unusable value is named with its raw text (a value that
# strips to nothing must not vanish silently)
text = ('ICCID KIC1 KID1 PUK1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' abcd\n')
res = outfile.parse_outfile(text)
self.assertNotIn('puk1', res['presets'][0])
self.assertTrue(any("PUK1 'abcd'" in s for s in res['report']['skipped']))
# ...while an *empty* PUK1 token cannot exist in a whitespace-separated
# row: the count would change, which is a file error (v3.29.2)
text = ('ICCID KIC1 KID1 PIN1 PUK1\n' + DIGITS_F + ' ' + K16A + ' ' + K16B + ' 1234\n')
res = outfile.parse_outfile(text)
self.assertEqual(res['presets'], [])
self.assertIn('the file is invalid', res['report']['error'])