When pySim-shell is called by a testcase, a logfile is createted. The logfile filename contains the testcase name. However, a testcase may run pySim-shell multiple times. In this case we overwrite the log from previous run. Let's use a counter to generate unique file names for each run, so that we won't lose logs from previous runs. Related: OS#6601 Change-Id: Ib2195d9b2231f74d0a6c4fb28f4889b6c45efb1e
459 lines
20 KiB
Python
459 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Testsuite for pySim-shell.py
|
|
#
|
|
# (C) 2024 by sysmocom - s.f.m.c. GmbH
|
|
# All Rights Reserved
|
|
#
|
|
# Author: Philipp Maier
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import os
|
|
import sys
|
|
import re
|
|
import unittest
|
|
import yaml
|
|
import csv
|
|
import inspect
|
|
from smartcard.CardType import ATRCardType
|
|
from smartcard.CardRequest import CardRequest
|
|
from smartcard.util import toHexString, toBytes
|
|
from smartcard.System import readers
|
|
from smartcard.scard import SCARD_SHARE_EXCLUSIVE
|
|
from string import Template
|
|
from pySim.utils import i2h, h2i, dec_iccid, boxed_heading_str
|
|
from time import sleep
|
|
from pathlib import Path
|
|
|
|
# Paths must originate at the top directory of the project repository
|
|
CONFIG_YAML="/tests/pySim-shell_test/config.yaml"
|
|
CARD_DATA_CSV="/tests/pySim-shell_test/card_data.csv"
|
|
|
|
class UnittestUtils(unittest.TestCase):
|
|
|
|
# Set to true to regenerate .ok files during test-run
|
|
regenerate = False
|
|
|
|
# Set to true to keep all temporary files (.log, .tmp, and files generated from templates)
|
|
keepfiles = False
|
|
|
|
# Print content of files that are read and/or compared
|
|
print_content = False
|
|
|
|
# The absolute path to the testcase that we are executing (where we find the .ok and .script files along with the
|
|
# concrete testcase implementation
|
|
test_dir = None
|
|
|
|
# The absolute path to the top level directory (where we also find pySim-shell.py)
|
|
top_dir = None
|
|
|
|
# All cards installed in this test-rig will get a record in this dict that will contain the data from config.toml
|
|
# and card_data.csv.
|
|
cards = {}
|
|
|
|
def __init__(self, *kwargs):
|
|
super().__init__(*kwargs)
|
|
self.maxDiff = None
|
|
self.__templates_generated = []
|
|
|
|
def __search_card(self, name:str, atr:str, iccid:str, eid:str) -> int:
|
|
""" Search a card by its ATR and ICCID/EID (name only for reference) """
|
|
reader_list=readers()
|
|
|
|
print("searching for card:")
|
|
if iccid:
|
|
print("ATR: %s" % atr)
|
|
print("ICCID: %s" % iccid)
|
|
elif eid:
|
|
print("ATR: %s" % atr)
|
|
print("EID: %s" % eid)
|
|
else:
|
|
raise RuntimeError("a card must be searched either by ICCID or EID")
|
|
|
|
for i in range(len(reader_list)):
|
|
|
|
# Connect to card reader
|
|
try:
|
|
reader_connection = reader_list[i].createConnection()
|
|
reader_connection.connect(mode = SCARD_SHARE_EXCLUSIVE)
|
|
except:
|
|
continue
|
|
|
|
# Match ATR
|
|
atr_found = i2h(reader_connection.getATR())
|
|
if atr_found.lower() != atr.lower():
|
|
print(" found ATR: %s -> no match, next card..." % atr_found)
|
|
reader_connection.disconnect()
|
|
continue
|
|
print(" found ATR: %s" % atr_found)
|
|
|
|
reader_connection.disconnect()
|
|
reader_connection.connect(mode = SCARD_SHARE_EXCLUSIVE)
|
|
|
|
# Match ICCID (UICC or UICC profile on an eUICC) or EID (eUICC)
|
|
if iccid:
|
|
response, sw1, sw2 = reader_connection.transmit(h2i("a0a40000022fe2"))
|
|
if sw1 != 0x9f:
|
|
raise RuntimeError("unable to select EF.ICCID on card %s (sw1=%02x, sw2=%02x)" % (name, sw1, sw2))
|
|
response, sw1, sw2 = reader_connection.transmit(h2i("a0b000000a"))
|
|
if [sw1, sw2] != [0x90, 0x00]:
|
|
raise RuntimeError("unable to read EF.ICCID from card %s (sw1=%02x, sw2=%02x)" % (name, sw1, sw2))
|
|
iccid_found = dec_iccid(i2h(response))
|
|
if iccid_found.lower() != iccid.lower():
|
|
print(" -> found ICCID: %s -> no match, next card..." % iccid_found)
|
|
reader_connection.disconnect()
|
|
continue
|
|
print(" -> found ICCID: %s" % iccid_found)
|
|
elif eid:
|
|
response, sw1, sw2 = reader_connection.transmit(h2i("0070000100"))
|
|
if [sw1, sw2] != [0x90, 0x00]:
|
|
raise RuntimeError("unable to open lchan 1 on card %s" % name)
|
|
response, sw1, sw2 = reader_connection.transmit(h2i("01A4040410A0000005591010FFFFFFFF8900000100"))
|
|
if sw1 != 0x61:
|
|
raise RuntimeError("unable to select ISD-R on card %s" % name)
|
|
response, sw1, sw2 = reader_connection.transmit(h2i("81E2910006BF3E035C015A"))
|
|
if [sw1, sw2] != [0x61, 0x15]:
|
|
raise RuntimeError("unable to retrieve EID on card %s" % name)
|
|
response, sw1, sw2 = reader_connection.transmit(h2i("01C0000015"))
|
|
if [sw1, sw2] != [0x90, 0x00]:
|
|
raise RuntimeError("unable to read EID from card %s" % name)
|
|
eid_found = i2h(response[5:])
|
|
if eid_found.lower() != eid.lower():
|
|
print(" -> found EID: %s -> no match, next card..." % eid_found)
|
|
reader_connection.disconnect()
|
|
continue
|
|
print(" -> found EID: %s" % eid_found)
|
|
|
|
# We found the card we were looking for!
|
|
reader_connection.disconnect()
|
|
return i
|
|
|
|
raise RuntimeError("missing card %s (atr:%s, eid:%s, iccid:%s), check test setup and configuration file (%s)" %
|
|
(name, atr, eid, iccid, CONFIG_YAML))
|
|
|
|
def __read_card_data(self, name, iccid, eid):
|
|
""" Find card data by EID or ICCID (name only for reference) """
|
|
|
|
if eid:
|
|
key = 'eid'
|
|
value = eid
|
|
elif iccid:
|
|
key = 'iccid'
|
|
value = iccid
|
|
else:
|
|
raise RuntimeError("iccid and eid parameter missing for card %s, check test setup and configuration file (%s)" % (name, CONFIG_YAML))
|
|
|
|
with open(self.top_dir + CARD_DATA_CSV, newline='') as csvfile:
|
|
csv_reader = csv.DictReader(csvfile)
|
|
for row in csv_reader:
|
|
if row.get(key) == value:
|
|
return row
|
|
raise RuntimeError("missing data for card %s (%s:%s), check card data file (%s)" % (name, key, value, CARD_DATA_CSV))
|
|
|
|
def setUp(self):
|
|
""" Initialize testsuite. This method is called automatically. It reads the test configuration file, finds and
|
|
sets the working directory of the executed testcase and ensures that the required card are present. """
|
|
print("")
|
|
|
|
testcasepath = inspect.getfile(self.__class__)
|
|
testcasename = testcasepath.split("/")[-2] + "." + self._testMethodName
|
|
print(boxed_heading_str("testcase: " + testcasename))
|
|
self.pysim_shell_log_counter = 0
|
|
|
|
# Find directories
|
|
self.test_dir = os.path.dirname(testcasepath)
|
|
print ("Test directory: " + self.test_dir)
|
|
self.top_dir = os.path.abspath(self.test_dir + "/../../../")
|
|
print ("Top directory: " + self.top_dir)
|
|
|
|
# Read test config
|
|
with open(self.top_dir + CONFIG_YAML, "r") as cfg:
|
|
config = yaml.load(cfg, Loader=yaml.FullLoader)
|
|
self.keepfiles = config['keepfiles']
|
|
self.regenerate = config['regenerate']
|
|
self.print_content = config['print_content']
|
|
|
|
if self.keepfiles:
|
|
print("keepfiles = True, will not delete generated files (.tmp, .log, and files generated from templates) on cleanup")
|
|
if self.regenerate:
|
|
print("regenerate = True, will regenerate .ok files from the .tmp that are generated during the testcase execution")
|
|
|
|
# Search cards
|
|
cards = config['cards']
|
|
for card in cards:
|
|
name = card['name']
|
|
atr = card['atr']
|
|
iccid = card.get('iccid', None)
|
|
eid = card.get('eid', None)
|
|
del card['name']
|
|
reader = self.__search_card(name, atr, iccid, eid)
|
|
card_data = self.__read_card_data(name, iccid, eid)
|
|
self.cards[name] = {**{'reader':reader}, **card, **card_data}
|
|
|
|
# Print discovered card information
|
|
print("Cards:")
|
|
for card in self.cards:
|
|
print(" %s:" % card)
|
|
for key in self.cards[card]:
|
|
print(" %s: %s" % (key, self.cards[card][key]))
|
|
|
|
print("initialization done -- continuing with testcase %s ..." % testcasename)
|
|
print("----------------------------------------------------------------------")
|
|
os.chdir(self.test_dir)
|
|
|
|
def tearDown(self):
|
|
""" Cleanup all temporary files (.tmp, files generated from templates and logfiles). This method is
|
|
called automatically """
|
|
|
|
print("----------------------------------------------------------------------")
|
|
print("testcase execution done -- cleaning up ...")
|
|
if not self.keepfiles:
|
|
os.system("rm -f ./*.tmp")
|
|
os.system("rm -f ./*.log")
|
|
for template in self.__templates_generated:
|
|
os.system("rm -f ./" + template)
|
|
|
|
def runPySimShell(self, cardname:str, script:str,
|
|
add_adm:bool = False,
|
|
add_csv:bool = False,
|
|
no_exceptions = False):
|
|
|
|
""" execute pySimShell.py. Each testcase should run pySim-shell at least once. The working directlry is the
|
|
testcase directory.
|
|
|
|
Args:
|
|
cardname : name of the card as specified in config file (CONFIG_YAML)
|
|
script : filename of the script file to execute.
|
|
add_adm : use the --adm option to supply an ADM key via the commandline
|
|
add_csv : use the --csv option to supply a CardKeyProvider file (CARD_DATA_CSV)
|
|
no_exceptions : fail the testcase in case any exceptions occurred while running pySim_shell
|
|
"""
|
|
|
|
logfile_name = "pySim-shell_" + self._testMethodName + "_" + str(self.pysim_shell_log_counter) + ".log"
|
|
self.pysim_shell_log_counter+=1
|
|
|
|
# Make sure the script file is available
|
|
if not os.access(script, os.R_OK):
|
|
raise RuntimeError("script file (%s) not found" % script)
|
|
|
|
# Form basic commandline
|
|
if cardname not in self.cards:
|
|
raise RuntimeError("unknown cardname %s, check test setup and configuration file (%s)" % (cardname, CONFIG_YAML))
|
|
reader = self.cards[cardname]['reader']
|
|
cmdline = self.top_dir + "/pySim-shell.py -p " + str(reader) + " --script " + str(script) + " --noprompt"
|
|
|
|
# Add optional arguments
|
|
if add_adm:
|
|
adm1 = self.cards[cardname]['adm1']
|
|
cmdline += " --pin-adm " + str(adm1)
|
|
if add_csv:
|
|
adm1 = self.cards[cardname]['adm1']
|
|
cmdline += " --csv " + self.top_dir + CARD_DATA_CSV
|
|
|
|
# Execute commandline
|
|
cmdline += " > " + logfile_name + " 2>&1"
|
|
print("Executing: " + cmdline)
|
|
rc = os.system(cmdline)
|
|
if rc:
|
|
raise RuntimeError("pySim-shell exits with error code %u" % rc)
|
|
|
|
# Check for exceptions
|
|
logfile = open(logfile_name)
|
|
logfile_content = logfile.read()
|
|
logfile.close()
|
|
exception_regex_compiled = re.compile('.*EXCEPTION.*')
|
|
exceptions_strings = re.findall(exception_regex_compiled, logfile_content)
|
|
if exceptions_strings != []:
|
|
print("The following exceptions occurred:")
|
|
for exceptions_string in exceptions_strings:
|
|
print(exceptions_string)
|
|
if no_exceptions:
|
|
self.assertTrue(False, "Unexpected exceptions occurred!")
|
|
else:
|
|
print("Note: the occurrence of exceptions may be expected, the sheer presence of exceptions is not necessarly an error.")
|
|
|
|
def __filter_lines(self, text:str, ignore_regex_list:list[str],
|
|
mask_regex_list:list[str], interesting_regex_list:list[str]):
|
|
""" Filter data from text lines using regex_lists """
|
|
|
|
# In case nor ignore or mask regexes are supplied, it makes no sense to continue. In this case, the full,
|
|
# unmodified text is returned.
|
|
if ignore_regex_list is None and mask_regex_list is None:
|
|
return text
|
|
|
|
# Compile regexes
|
|
ignore_regex_compiled_list = []
|
|
if ignore_regex_list:
|
|
for regex in ignore_regex_list:
|
|
ignore_regex_compiled_list.append(re.compile(regex))
|
|
mask_regex_compiled_list = []
|
|
if mask_regex_list:
|
|
for regex in mask_regex_list:
|
|
mask_regex_compiled_list.append(re.compile(regex))
|
|
interesting_regex_compiled_list = []
|
|
if interesting_regex_list:
|
|
for regex in interesting_regex_list:
|
|
interesting_regex_compiled_list.append(re.compile(regex))
|
|
|
|
# Split up text into individual lines
|
|
text_lines_filtered = []
|
|
text_lines = text.splitlines()
|
|
|
|
# Go through the text line by line and apply regexes
|
|
for line in text_lines:
|
|
# Detect interesting line, such a line must not be modified as it is deemed as interesting
|
|
interesting_line = False
|
|
for interesting_regex_compiled in interesting_regex_compiled_list:
|
|
if re.findall(interesting_regex_compiled, line) != []:
|
|
interesting_line = True
|
|
break
|
|
|
|
# Anything else that is not deemed as interesting gets the ignore+mask regexes applied
|
|
if not interesting_line:
|
|
for ignore_regex_compiled in ignore_regex_compiled_list:
|
|
line = re.sub(ignore_regex_compiled, "", line)
|
|
for mask_regex_compiled in mask_regex_compiled_list:
|
|
line = re.sub(mask_regex_compiled, "*", line)
|
|
|
|
# Add the modified line to the output (strip spaces)
|
|
line = line.strip()
|
|
if line != "":
|
|
text_lines_filtered.append(line)
|
|
|
|
return "\n".join(text_lines_filtered)
|
|
|
|
def assertEqualFiles(self, out_file_path:str, ok_file_path:str = None,
|
|
ignore_regex_list:list[str] = None,
|
|
mask_regex_list:list[str] = None,
|
|
interesting_regex_list:list[str] = None):
|
|
""" Compare an out-file against an ok-file. If differences are detected an assertion is thrown and the
|
|
testcase fails. This method can also be used to re-generate the ok-file when self.regenerate is set to
|
|
True.
|
|
|
|
Args:
|
|
out_file_path : path to the file which is generated by the testcase (e.g. test.tmp)
|
|
ok_file_path : file to compara against (e.g. test.ok, optional when .ok and .tmp file have the same basename)
|
|
ignore_regex_list : a list with regex strings to remove certain zones in both files before comparison.
|
|
mask_regex_list : a list with regex strings to mask certain zones in both files before comparison.
|
|
interesting_regex_list : a list with regex strings to select certain lines in the file that shall not be
|
|
affected by ignore_regex_list.
|
|
"""
|
|
|
|
if ok_file_path is None:
|
|
path = Path(out_file_path)
|
|
ok_file_path = path.with_suffix('.ok')
|
|
|
|
# Read/regenerate files
|
|
out_file = open(out_file_path)
|
|
out_file_content = out_file.read()
|
|
out_file.close()
|
|
if self.regenerate:
|
|
print("File comparison: regenerating (overwriting) content of %s with content of %s" %
|
|
(os.path.basename(out_file_path), os.path.basename(ok_file_path)))
|
|
ok_file = open(ok_file_path, "w")
|
|
ok_file.write(out_file_content)
|
|
ok_file.close()
|
|
return
|
|
ok_file = open(ok_file_path)
|
|
ok_file_content = ok_file.read()
|
|
ok_file.close()
|
|
|
|
# Apply line based filters
|
|
out_file_content = self.__filter_lines(out_file_content, ignore_regex_list, mask_regex_list, interesting_regex_list)
|
|
ok_file_content = self.__filter_lines(ok_file_content, ignore_regex_list, mask_regex_list, interesting_regex_list)
|
|
if self.print_content:
|
|
print("File comparison: the following file contents are compared with each other:")
|
|
print("Comparing (%s)" % os.path.basename(out_file_path))
|
|
print("-----------------------8<-----------------------")
|
|
print(out_file_content)
|
|
print("-----------------------8<-----------------------")
|
|
print("With (%s)" % os.path.basename(ok_file_path))
|
|
print("-----------------------8<-----------------------")
|
|
print(ok_file_content)
|
|
print("-----------------------8<-----------------------")
|
|
|
|
# Final comparison
|
|
if out_file_content == ok_file_content:
|
|
print("File comparison: content of %s matches content of %s -- ok" %
|
|
(os.path.basename(out_file_path), os.path.basename(ok_file_path)))
|
|
return
|
|
|
|
# Generate test error (this assertion will always fail, we just use it to generate an error message and a diff)
|
|
self.assertEqual(ok_file_content, out_file_content,
|
|
"File comparison: content %s does not match content of %s -- test failed" %
|
|
(os.path.basename(out_file_path), os.path.basename(ok_file_path)))
|
|
|
|
|
|
def equipTemplate(self, output_path:str, template_path:str = None, **kwargs):
|
|
""" Equip a template file with useful contents. A template may contain placeholders in the form of $MY_VAR (see
|
|
also https://docs.python.org/2/library/string.html#template-strings).
|
|
|
|
Args:
|
|
output_path : path to the file which is generated by the from the template (e.g. test.script)
|
|
template_path : path to the template file (e.g. test.template, optional when .template and .script file have
|
|
the same basename)
|
|
"""
|
|
|
|
if template_path is None:
|
|
path = Path(output_path)
|
|
template_path = path.with_suffix('.template')
|
|
|
|
print("Template: using template %s to generate file %s" % (template_path, output_path))
|
|
|
|
template_file = open(template_path)
|
|
template_content = template_file.read()
|
|
template_file.close()
|
|
|
|
output_template = Template(template_content)
|
|
output_content = output_template.substitute(**kwargs)
|
|
|
|
output_file = open(output_path, "w")
|
|
output_file.write(output_content)
|
|
output_file.close()
|
|
|
|
self.__templates_generated.append(output_path)
|
|
|
|
|
|
def getFileContent(self, file_path:str, substr_regex:str = None) -> str:
|
|
""" Get contents from a file, optionally apply a regex to extract an interesting substring
|
|
|
|
Args:
|
|
file_path : path to the file to read (e.g. test.tmp)
|
|
substr_regex : a regex expression to extract an interesting substring from the file content
|
|
"""
|
|
|
|
print("File: reading content of file %s" % file_path)
|
|
if not os.access(file_path, os.R_OK):
|
|
self.assertTrue(False, "file (%s) not readable!" % file_path)
|
|
file = open(file_path)
|
|
file_content = file.read()
|
|
file.close()
|
|
if self.print_content:
|
|
print("Content of File (%s):" % os.path.basename(file_path))
|
|
print("-----------------------8<-----------------------")
|
|
print(file_content)
|
|
print("-----------------------8<-----------------------")
|
|
|
|
if substr_regex:
|
|
substr_regex_compiled = (re.compile(substr_regex))
|
|
file_content = re.search(substr_regex_compiled, file_content).group(1)
|
|
if self.print_content:
|
|
print("Content of File (%s) after regex ('%s') applied:" % (os.path.basename(file_path), substr_regex))
|
|
print("-----------------------8<-----------------------")
|
|
print(file_content)
|
|
print("-----------------------8<-----------------------")
|
|
|
|
return file_content
|