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:
+1
-1
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v313';
|
||||
const CACHE = 'simple-v314';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user