mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
POST /api/v3/plugins/action runs a plugin's script through a generated
Python wrapper, and the params went into that wrapper's source as
`params = <json.dumps(params)>`. JSON true, false and null are undefined
names in Python, so any params holding one made the wrapper die with a
NameError before the script ran, and the route answered "Action failed".
The plugin file manager's category toggle sends {"category_name": ...,
"enabled": true}, so of-the-day's category toggle failed every time.
The wrapper now reads the params from its own stdin (json.loads) and the
route passes them there; nothing taken from the request is written into
the generated source any more. The script's side is unchanged: the same
json.dumps(params) on its stdin, LEDMATRIX_ROOT set, stdout parsed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
3.8 KiB
Python
94 lines
3.8 KiB
Python
"""POST /api/v3/plugins/action hands ``params`` to the plugin's script intact.
|
|
|
|
The route runs the script through a generated wrapper, and the params went
|
|
into that wrapper as Python source: ``params = {json.dumps(params)}``. JSON is
|
|
not Python. ``true``, ``false`` and ``null`` are undefined names there, so any
|
|
params holding a boolean or a null died with a NameError before the script
|
|
ran. The plugin file manager's category toggle sends ``{"category_name": ...,
|
|
"enabled": true}``, so of-the-day's category toggle failed every time with
|
|
"Action failed".
|
|
|
|
The script's side of the contract is unchanged and pinned here too: the
|
|
params arrive on stdin as one JSON document, LEDMATRIX_ROOT is set, and what
|
|
the script prints to stdout is what the route parses.
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
|
|
ACTION_URL = "/api/v3/plugins/action"
|
|
|
|
# The action script: report what it was handed, as JSON on stdout.
|
|
ECHO_SCRIPT = (
|
|
"import json, os, sys\n"
|
|
"raw = sys.stdin.read()\n"
|
|
"print(json.dumps({'status': 'success', 'got': json.loads(raw),\n"
|
|
" 'root': os.environ.get('LEDMATRIX_ROOT')}))\n"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def echo_plugin(tmp_path, api_v3_module, monkeypatch):
|
|
plugin_dir = tmp_path / "demo"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "manifest.json").write_text(json.dumps({
|
|
"id": "demo",
|
|
"web_ui_actions": [{"id": "toggle", "type": "script", "script": "echo.py"}],
|
|
}), encoding="utf-8")
|
|
(plugin_dir / "echo.py").write_text(ECHO_SCRIPT, encoding="utf-8")
|
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(plugin_dir)
|
|
|
|
# The route runs `python3`; use this interpreter, so the test does not
|
|
# depend on what that name resolves to here.
|
|
real_run = subprocess.run
|
|
|
|
def run(cmd, *args, **kwargs):
|
|
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
|
|
cmd = [sys.executable] + cmd[1:]
|
|
return real_run(cmd, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(subprocess, "run", run)
|
|
return plugin_dir
|
|
|
|
|
|
@pytest.mark.parametrize("params", [
|
|
{"category_name": "jokes", "enabled": True}, # the file manager's toggle
|
|
{"category_name": "jokes", "enabled": False},
|
|
{"filename": None},
|
|
{"nested": {"list": [1, None, True, 2.5], "empty": {}}},
|
|
{"text": "café ✓ \U0001F600"},
|
|
{"text": "he said \"hi\" and 'bye' \\ ''' \"\"\" \n\t end"},
|
|
], ids=["true", "false", "null", "nested", "unicode", "quotes"])
|
|
def test_the_script_receives_the_params_it_was_sent(api_v3_client, echo_plugin, params):
|
|
response = api_v3_client.post(ACTION_URL, json={
|
|
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
|
body = response.get_json()
|
|
assert response.status_code == 200, body
|
|
assert body["got"] == params
|
|
|
|
|
|
def test_a_param_cannot_run_code_in_the_wrapper(api_v3_client, echo_plugin, tmp_path):
|
|
marker = tmp_path / "PWNED"
|
|
hostile = "\"}\nopen(%r, 'w').write('ran')\n#" % str(marker)
|
|
params = {"name": hostile, "flag": True}
|
|
response = api_v3_client.post(ACTION_URL, json={
|
|
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["got"] == params
|
|
assert not marker.exists(), "a param value ran as code"
|
|
|
|
|
|
def test_the_script_still_gets_ledmatrix_root(api_v3_client, echo_plugin, api_v3_module):
|
|
response = api_v3_client.post(ACTION_URL, json={
|
|
"plugin_id": "demo", "action_id": "toggle", "params": {"enabled": True}})
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["root"] == str(api_v3_module.PROJECT_ROOT)
|