Files
hermes-webui/tests/test_cron_query_param_validation.py
T
nesquena-hermes 89b034b925 Release v0.51.232 — Release GZ (stage-q2) (#3485)
## Release v0.51.232 — Release GZ (stage-q2)

Two low-risk fixes batched into one release. All gates green.

### Fixes
| PR | Author | Fix |
|----|--------|-----|
| #3473 | @Mubashirrrr | Guard malformed/negative numeric query params on the cron endpoints (`/api/crons/output`, `/api/crons/recent`) — no more 500 on `?limit=abc`, no oldest-entry drop on a negative limit |
| #3480 | @koshikai | Translate 80 previously-untranslated Japanese (`ja`) locale strings (MCP controls, tool summaries, toasts) — interpolation placeholders preserved, no keys added/removed |

### Gate results
- **Full pytest suite**: 7446 passed, 7 skipped, 3 xpassed, **0 failed**
- **ESLint runtime gate**: CLEAN
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (`/`, `/#settings`, `/#sessions` — zero console errors)
- **Codex (regression)**: SAFE TO SHIP (flagged a CHANGELOG endpoint-path typo `/api/cron/` → `/api/crons/`, fixed)
- **Opus (correctness)**: SAFE TO SHIP (flagged an inline-comment imprecision about which entries a negative slice drops — fixed in both the comment and CHANGELOG)

Co-authored-by: Mubashirrrr <Mubashirrrr@users.noreply.github.com>
Co-authored-by: koshikai <koshikai@users.noreply.github.com>
2026-06-03 09:24:51 -07:00

153 lines
4.8 KiB
Python

"""Regression: malformed numeric query params on cron endpoints return a
well-formed response instead of crashing with an uncaught ValueError (500).
``GET /api/crons/output?limit=<x>`` and ``GET /api/crons/recent?since=<x>``
parsed their numeric params with a bare ``int()`` / ``float()``. A non-numeric
value raised ValueError, which propagated to the top-level request handler and
surfaced as a generic HTTP 500 — inconsistent with sibling handlers
(``_handle_cron_run_detail``, ``_handle_insights``, ``_handle_notes_search``)
that all guard the same pattern and fall back to a default.
``_handle_cron_output`` additionally clamps ``limit`` to a positive range so a
negative value can never reach the ``files[:limit]`` slice (``files[:-1]`` would
silently drop the newest output rather than return it).
"""
from __future__ import annotations
import io
import json
import sys
import types
from types import SimpleNamespace
class _JSONHandler:
def __init__(self):
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
pass
def _payload(handler):
return json.loads(handler.wfile.getvalue().decode("utf-8"))
def _stub_cron_jobs(monkeypatch, *, output_dir=None, jobs=None):
cron_pkg = types.ModuleType("cron")
cron_pkg.__path__ = []
cron_jobs = types.ModuleType("cron.jobs")
if output_dir is not None:
cron_jobs.OUTPUT_DIR = output_dir
if jobs is not None:
cron_jobs.list_jobs = lambda include_disabled=True: jobs
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
return cron_jobs
def test_cron_output_non_numeric_limit_does_not_500(monkeypatch, tmp_path):
import api.routes as routes
_stub_cron_jobs(monkeypatch, output_dir=tmp_path / "cron-out")
handler = _JSONHandler()
# Before the fix this raised ValueError -> 500.
routes._handle_cron_output(
handler, SimpleNamespace(query="job_id=abc123&limit=notanint")
)
assert handler.status == 200
body = _payload(handler)
assert body["job_id"] == "abc123"
assert body["outputs"] == []
def test_cron_output_negative_limit_is_clamped(monkeypatch, tmp_path):
import api.routes as routes
out_dir = tmp_path / "cron-out" / "job42"
out_dir.mkdir(parents=True)
# Three outputs with distinct mtimes; newest must always be returned.
for i in range(3):
f = out_dir / f"run-{i}.md"
f.write_text(f"## Response\noutput {i}\n", encoding="utf-8")
import os
os.utime(f, (1000 + i, 1000 + i))
_stub_cron_jobs(monkeypatch, output_dir=tmp_path / "cron-out")
handler = _JSONHandler()
# limit=-3 with exactly 3 files: an unclamped files[:-3] slice yields []
# (every output silently dropped). Clamped to >= 1 it returns the newest.
routes._handle_cron_output(
handler, SimpleNamespace(query="job_id=job42&limit=-3")
)
assert handler.status == 200
body = _payload(handler)
assert len(body["outputs"]) >= 1
assert body["outputs"][0]["filename"] == "run-2.md"
def test_cron_output_valid_limit_still_works(monkeypatch, tmp_path):
import api.routes as routes
_stub_cron_jobs(monkeypatch, output_dir=tmp_path / "cron-out")
handler = _JSONHandler()
routes._handle_cron_output(
handler, SimpleNamespace(query="job_id=nonexistent&limit=20")
)
assert handler.status == 200
assert _payload(handler)["outputs"] == []
def test_cron_recent_non_numeric_since_does_not_500(monkeypatch):
import api.routes as routes
_stub_cron_jobs(
monkeypatch,
jobs=[
{"id": "a", "name": "Job A", "last_run_at": 50, "last_status": "success"},
],
)
handler = _JSONHandler()
# Before the fix this raised ValueError -> 500.
routes._handle_cron_recent(handler, SimpleNamespace(query="since=notanum"))
assert handler.status == 200
body = _payload(handler)
assert body["since"] == 0.0
# since defaults to the epoch, so the completed job is still reported.
assert {item["job_id"] for item in body["completions"]} == {"a"}
def test_cron_recent_valid_since_still_filters(monkeypatch):
import api.routes as routes
_stub_cron_jobs(
monkeypatch,
jobs=[
{"id": "old", "name": "Old", "last_run_at": 5, "last_status": "success"},
{"id": "new", "name": "New", "last_run_at": 50, "last_status": "success"},
],
)
handler = _JSONHandler()
routes._handle_cron_recent(handler, SimpleNamespace(query="since=10"))
assert handler.status == 200
body = _payload(handler)
assert body["since"] == 10.0
assert {item["job_id"] for item in body["completions"]} == {"new"}