From 56dbc3f9156eea507bf2e54604370b748d72bf83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 29 Sep 2026 03:31:09 +0300 Subject: [PATCH] fix: test-script endpoints answer on POST (v3.6.41) pysimFetch() sends POST whenever a body is passed, but /api/test/run, /api/test/stop and /api/test/clear were registered in _do_GET (since the phase-1 commit 3a4a098): every Start fell through to the catch-all 404 {'error': 'Not found'} and the feature had never been exercised over HTTP (a browser GET would not carry the JSON body either). The three branches moved into _do_POST; safe under the RLock-based _CARD_LOCK because _test_run_start re-acquires it before spawning the worker. New tests/test_fetch_routes.py: - a PWA<->server route/method matrix (every body-carrying pysimFetch needs a POST route, body-less calls a GET/POST route; routes are extracted from _do_GET/_do_POST incl. the 'self.path in (...)' and startswith forms) - verified to fail on the pre-fix routing with exactly the three endpoints; - an HTTP smoke test on the real handler: POST /api/test/run {} -> 400, POST stop/clear -> 200, GET /api/test/run -> 404. sw cache simple-v314; tests: 527 Python / 675 Node. --- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/server.py | 122 +++++++++++++++++----------------- tests/test_fetch_routes.py | 111 +++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 64 deletions(-) create mode 100644 tests/test_fetch_routes.py diff --git a/frontend/index.html b/frontend/index.html index 2b46eb8..d2d3882 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1732,7 +1732,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.6.40'; +const SIMPLE_VERSION = '3.6.41'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== diff --git a/frontend/sw.js b/frontend/sw.js index 589f664..abc2fb3 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v313'; +const CACHE = 'simple-v314'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 576fd61..f362cdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.40" +version = "3.6.41" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 0f505db..eff6e99 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.40' +VERSION = '3.6.41' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -5584,66 +5584,6 @@ class PysimHandler(BaseHTTPRequestHandler): resp = {('%02X' % q): v for q, v in _PLI_DATA.items()} self._send_json(resp) self._log_resp(resp) - elif self.path == '/api/test/run': - body = self._read_body() - self._log_req(body) - if _TEST_RUNNING: - resp = {'error': 'a test script is already running'} - self._send_json(resp, 409) - self._log_resp(resp) - return - try: - script = testscript.normalise_script(body.get('script'), _test_command_type) - except testscript.ScriptError as e: - resp = {'error': str(e)} - self._send_json(resp, 400) - self._log_resp(resp) - return - preset = body.get('preset') or {} - needs_scp80 = any(s['type'] == 'action' and s['kind'] == 'scp80' - for s in script['steps']) - err = _test_preset_error(script, preset) if needs_scp80 else None - if err: - resp = {'error': err} - self._send_json(resp, 400) - self._log_resp(resp) - return - try: - _test_run_start(self.server, script, preset) - except Exception as e: - resp = {'error': 'could not start the test run: %s' % e} - self._send_json(resp, 500) - self._log_resp(resp) - return - resp = _test_state_snapshot() - self._send_json(resp) - self._log_resp(resp) - elif self.path == '/api/test/stop': - self._log_req() - with _TEST_LOCK: - if _TEST_RUN['running']: - _TEST_RUN['stop'] = True - resp = _test_state_snapshot() - self._send_json(resp) - self._log_resp(resp) - elif self.path == '/api/test/clear': - self._log_req() - with _TEST_LOCK: - busy = bool(_TEST_RUN['running']) - if not busy: - _TEST_RUN.update({ - 'running': False, 'stop': False, 'name': None, 'status': None, - 'session': None, 'index': 0, 'total': 0, 'steps': [], - 'preset': None, 'scp80_counter': None, - 'started': None, 'finished': None, 'error': None, - }) - if busy: - resp = {'error': 'test script is running'} - self._send_json(resp, 409) - else: - resp = _test_state_snapshot() - self._send_json(resp) - self._log_resp(resp) elif self.path == '/api/poll-status': resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL, 'card_disabled': _POLL_DISABLED_BY_CARD} @@ -6814,6 +6754,66 @@ class PysimHandler(BaseHTTPRequestHandler): resp = {'ok': True, 'seq': _BIP.seq} self._send_json(resp) self._log_resp(resp) + elif self.path == '/api/test/run': + body = self._read_body() + self._log_req(body) + if _TEST_RUNNING: + resp = {'error': 'a test script is already running'} + self._send_json(resp, 409) + self._log_resp(resp) + return + try: + script = testscript.normalise_script(body.get('script'), _test_command_type) + except testscript.ScriptError as e: + resp = {'error': str(e)} + self._send_json(resp, 400) + self._log_resp(resp) + return + preset = body.get('preset') or {} + needs_scp80 = any(s['type'] == 'action' and s['kind'] == 'scp80' + for s in script['steps']) + err = _test_preset_error(script, preset) if needs_scp80 else None + if err: + resp = {'error': err} + self._send_json(resp, 400) + self._log_resp(resp) + return + try: + _test_run_start(self.server, script, preset) + except Exception as e: + resp = {'error': 'could not start the test run: %s' % e} + self._send_json(resp, 500) + self._log_resp(resp) + return + resp = _test_state_snapshot() + self._send_json(resp) + self._log_resp(resp) + elif self.path == '/api/test/stop': + self._log_req() + with _TEST_LOCK: + if _TEST_RUN['running']: + _TEST_RUN['stop'] = True + resp = _test_state_snapshot() + self._send_json(resp) + self._log_resp(resp) + elif self.path == '/api/test/clear': + self._log_req() + with _TEST_LOCK: + busy = bool(_TEST_RUN['running']) + if not busy: + _TEST_RUN.update({ + 'running': False, 'stop': False, 'name': None, 'status': None, + 'session': None, 'index': 0, 'total': 0, 'steps': [], + 'preset': None, 'scp80_counter': None, + 'started': None, 'finished': None, 'error': None, + }) + if busy: + resp = {'error': 'test script is running'} + self._send_json(resp, 409) + else: + resp = _test_state_snapshot() + self._send_json(resp) + self._log_resp(resp) else: self._send_json({'error': _err('not_found', lang)}, 404) self._log_resp({'error': _err('not_found', lang)}) diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py new file mode 100644 index 0000000..6fcc9d9 --- /dev/null +++ b/tests/test_fetch_routes.py @@ -0,0 +1,111 @@ +"""PWA <-> server HTTP contract tests. + +`pysimFetch(path, body)` sends POST whenever a body is passed and GET +otherwise; the server's routes live in `PysimHandler._do_GET` / `_do_POST`. +The test-script endpoints were registered in `_do_GET` while the PWA POSTs +to them, so every Start answered 404 "Not found" (live 2026-09-29; present +since the phase-1 commit and never exercised over HTTP). The matrix below +is the guard for that contract; the smoke test boots the real handler and +checks the three endpoints. +""" + +import json +import pathlib +import re +import threading +import unittest +import urllib.error +import urllib.request + +from pysim_simple_server.__main__ import _build_http_server +from pysim_simple_server.server import PysimHandler + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _pwa_calls(): + """[(path, has_body)] for every literal pysimFetch() call in the PWA.""" + html = (ROOT / 'frontend' / 'index.html').read_text(encoding='utf-8') + return [(m.group(1).split('?')[0], m.group(2) == ',') + for m in re.finditer(r"pysimFetch\(\s*'([^']+)'\s*(,|\))", html)] + + +def _server_routes(): + """((GET exact, GET prefixes), (POST exact, POST prefixes)) of /api paths.""" + src = (ROOT / 'pysim_simple_server' / 'server.py').read_text(encoding='utf-8') + get_seg = src[src.index('def _do_GET('):src.index('def do_POST(')] + post_seg = src[src.index('def _do_POST('):src.index('def log_message(')] + + def routes(seg): + exact = set(re.findall(r"self\.path == '(/api/[^']+)'", seg)) + for group in re.findall(r"self\.path in \(([^)]*)\)", seg): + exact |= set(re.findall(r"'(/api/[^']+)'", group)) + prefixes = set(re.findall(r"self\.path\.startswith\('(/api/[^']+)'\)", seg)) + return exact, prefixes - {'/api/'} + + return routes(get_seg), routes(post_seg) + + +class FetchRouteMatrixTests(unittest.TestCase): + def test_every_fetch_matches_a_route_with_the_right_method(self): + (get, get_pref), (post, post_pref) = _server_routes() + self.assertTrue(get and post, 'route extraction found nothing') + + def in_routes(path, exact, prefixes): + return path in exact or any(path.startswith(p) for p in prefixes) + + for path, has_body in _pwa_calls(): + with self.subTest(path=path, post=has_body): + if has_body: + self.assertTrue(in_routes(path, post, post_pref), + '%s is POSTed (body) but has no POST route' % path) + else: + self.assertTrue( + in_routes(path, get, get_pref) or in_routes(path, post, post_pref), + '%s is fetched without a body but has no GET/POST route' % path) + + +class TestScriptHttpTests(unittest.TestCase): + """The test-script endpoints must answer on POST (the PWA's method).""" + + def setUp(self): + self.server = _build_http_server('127.0.0.1', 0, PysimHandler) + self.server.log_requests = False + self.server.app = None + threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.port = self.server.server_address[1] + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def _post(self, path, body): + req = urllib.request.Request( + 'http://127.0.0.1:%d%s' % (self.port, path), + data=json.dumps(body).encode(), + headers={'Content-Type': 'application/json'}, method='POST') + try: + with urllib.request.urlopen(req, timeout=5) as res: + return res.status, json.loads(res.read() or b'{}') + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b'{}') + + def test_start_stop_clear_answer_on_post(self): + # an empty script is invalid, but it proves the route exists (400) + status, resp = self._post('/api/test/run', {}) + self.assertEqual(status, 400, resp) + self.assertIn('error', resp) + status, resp = self._post('/api/test/stop', {}) + self.assertEqual(status, 200, resp) + status, resp = self._post('/api/test/clear', {}) + self.assertEqual(status, 200, resp) + + def test_the_test_endpoints_are_not_get_routes(self): + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen('http://127.0.0.1:%d/api/test/run' % self.port, timeout=5) + self.assertEqual(cm.exception.code, 404) + cm.exception.close() + + +if __name__ == '__main__': + unittest.main()