From 72266e24f5f62caeae49cbe75d02183f8ec6176c Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:32:48 -0400 Subject: [PATCH] fix(api): accept RRD query parameters during HTTP dispatch --- repeater/web/api_endpoints.py | 3 +- tests/test_rrd_data_http_dispatch.py | 113 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/test_rrd_data_http_dispatch.py diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index b5346f6..02c2564 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -4277,7 +4277,8 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() - def rrd_data(self): + def rrd_data(self, **kwargs): + # CherryPy dispatches query parameters as keywords before _get_params reads them. try: params = self._get_params( {"start_time": None, "end_time": None, "resolution": "average"} diff --git a/tests/test_rrd_data_http_dispatch.py b/tests/test_rrd_data_http_dispatch.py new file mode 100644 index 0000000..dbf922b --- /dev/null +++ b/tests/test_rrd_data_http_dispatch.py @@ -0,0 +1,113 @@ +"""Exercise RRD query parameters through authenticated CherryPy dispatch.""" + +import json +import threading +from http.client import HTTPConnection +from types import SimpleNamespace +from unittest.mock import Mock +from wsgiref.simple_server import WSGIRequestHandler, make_server + +import cherrypy +import pytest + +from repeater.web.api_endpoints import APIEndpoints +from repeater.web.auth.cherrypy_tool import register_require_auth_tool + + +class QuietHandler(WSGIRequestHandler): + def log_message(self, format, *args): + pass + + +@pytest.fixture +def rrd_http(monkeypatch): + # Direct-handler tests may leave synthetic request/response objects behind. + monkeypatch.setattr(cherrypy, "request", cherrypy._ThreadLocalProxy("request")) + monkeypatch.setattr(cherrypy, "response", cherrypy._ThreadLocalProxy("response")) + register_require_auth_tool() + monkeypatch.setitem(cherrypy.config, "jwt_handler", SimpleNamespace(verify_jwt=lambda _: None)) + monkeypatch.setitem( + cherrypy.config, + "token_manager", + SimpleNamespace( + verify_token=lambda token: ( + {"id": "test", "name": "test"} if token == "test-key" else None + ) + ), + ) + storage = SimpleNamespace( + get_metrics_data=Mock(return_value={"timestamps": [1, 2], "metrics": {}}) + ) + api = APIEndpoints.__new__(APIEndpoints) + api.daemon_instance = SimpleNamespace(repeater_handler=SimpleNamespace(storage=storage)) + app = cherrypy.Application( + SimpleNamespace(api=api), + config={"/api": {"tools.require_auth.on": True}}, + ) + with make_server("127.0.0.1", 0, app, handler_class=QuietHandler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def get(query="", *, authenticated=True): + connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5) + try: + headers = {"X-API-Key": "test-key"} if authenticated else {} + connection.request("GET", "/api/rrd_data" + query, headers=headers) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + try: + yield get, storage.get_metrics_data + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_rrd_query_parameters_reach_storage_over_http(rrd_http): + get, read_metrics = rrd_http + status, body = get("?start_time=1&end_time=2&resolution=average") + assert status == 200, body + assert json.loads(body) == { + "success": True, + "data": {"timestamps": [1, 2], "metrics": {}}, + } + read_metrics.assert_called_once_with(start_time="1", end_time="2", resolution="average") + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + ("", {"start_time": None, "end_time": None, "resolution": "average"}), + ( + "?start_time=10&resolution=max", + {"start_time": "10", "end_time": None, "resolution": "max"}, + ), + ], +) +def test_rrd_http_preserves_defaults_and_partial_queries(rrd_http, query, expected): + get, read_metrics = rrd_http + status, body = get(query) + assert status == 200, body + assert json.loads(body)["success"] is True + read_metrics.assert_called_once_with(**expected) + + +def test_rrd_http_query_requires_authentication(rrd_http): + get, read_metrics = rrd_http + status, _ = get("?start_time=1&end_time=2&resolution=average", authenticated=False) + assert status == 401 + read_metrics.assert_not_called() + + +def test_rrd_http_preserves_storage_parameter_errors(rrd_http): + get, read_metrics = rrd_http + read_metrics.side_effect = ValueError("invalid time range") + status, body = get("?start_time=invalid&end_time=2&resolution=average") + assert status == 200, body + assert json.loads(body) == { + "success": False, + "error": "Invalid parameter format: invalid time range", + } + read_metrics.assert_called_once_with(start_time="invalid", end_time="2", resolution="average")