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.
This commit is contained in:
2026-09-29 03:31:09 +03:00
parent 904eca1758
commit 56dbc3f915
5 changed files with 175 additions and 64 deletions
+1 -1
View File
@@ -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 =====
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v313';
const CACHE = 'simple-v314';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -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.
+61 -61
View File
@@ -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)})
+111
View File
@@ -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()