diff --git a/src/opensquilla/cli/cron_cmd.py b/src/opensquilla/cli/cron_cmd.py index 3241cfcf3..91b1c786c 100644 --- a/src/opensquilla/cli/cron_cmd.py +++ b/src/opensquilla/cli/cron_cmd.py @@ -11,7 +11,7 @@ from rich.table import Table from opensquilla.cli.gateway_rpc import confirm_or_exit, run_gateway_sync -from opensquilla.cli.output import print_json +from opensquilla.cli.output import exit_invalid_request, print_json from opensquilla.cli.ui import ACCENT_HEADER, console cron_app = typer.Typer(help="Inspect and manage scheduled OpenSquilla runs.") @@ -858,6 +858,12 @@ def cron_runs( json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"), ) -> None: """List recent runs for a cron job.""" + if limit < 1: + exit_invalid_request( + "--limit must be >= 1", + json_output=json_output, + details={"parameter": "limit", "minimum": 1}, + ) async def _run(client): return await client.call("cron.runs", {"id": job_id, "limit": limit}) diff --git a/src/opensquilla/cli/output.py b/src/opensquilla/cli/output.py index c0f20b218..29d45090e 100644 --- a/src/opensquilla/cli/output.py +++ b/src/opensquilla/cli/output.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, NoReturn import typer @@ -50,3 +50,20 @@ def emit_error( ) else: typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True) + + +def exit_invalid_request( + message: str, + *, + json_output: bool = False, + details: Any | None = None, +) -> NoReturn: + """Report invalid CLI input through the standard error envelope.""" + + emit_error( + message, + json_output=json_output, + code="INVALID_REQUEST", + details=details, + ) + raise typer.Exit(2) diff --git a/src/opensquilla/cli/sessions_cmd.py b/src/opensquilla/cli/sessions_cmd.py index 878bfaf19..4fe919d7d 100644 --- a/src/opensquilla/cli/sessions_cmd.py +++ b/src/opensquilla/cli/sessions_cmd.py @@ -14,7 +14,7 @@ from opensquilla.cli.chat.session_state import messages_to_markdown from opensquilla.cli.gateway_client import session_history_all from opensquilla.cli.gateway_rpc import default_gateway_url, run_gateway_sync -from opensquilla.cli.output import print_json +from opensquilla.cli.output import exit_invalid_request, print_json from opensquilla.cli.ui import ACCENT, ACCENT_HEADER, console, error_panel app = typer.Typer(help="Manage chat sessions.") @@ -135,6 +135,12 @@ def sessions_list( json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"), ) -> None: """List recent sessions.""" + if limit < 1: + exit_invalid_request( + "--limit must be >= 1", + json_output=json_output, + details={"parameter": "limit", "minimum": 1}, + ) since_dt = _parse_since(since) async def _run(client): diff --git a/src/opensquilla/gateway/rpc_sessions.py b/src/opensquilla/gateway/rpc_sessions.py index 79f3399a2..83f438179 100644 --- a/src/opensquilla/gateway/rpc_sessions.py +++ b/src/opensquilla/gateway/rpc_sessions.py @@ -2781,6 +2781,14 @@ def empty_payload() -> dict[str, Any]: is_guest = GuestRpcPolicy.is_guest(ctx) owner_id = getattr(ctx.principal, "guest_owner_id", None) if is_guest else None + if not is_guest: + try: + numeric_limit = int(limit) + except (TypeError, ValueError): + pass + else: + if numeric_limit < 1: + raise ValueError("params.limit must be >= 1") if count_only: count_sessions = getattr(storage, "count_sessions", None) if callable(count_sessions): diff --git a/tests/test_cli/test_cli_product_completeness.py b/tests/test_cli/test_cli_product_completeness.py index 537ae27e2..5f1c39db6 100644 --- a/tests/test_cli/test_cli_product_completeness.py +++ b/tests/test_cli/test_cli_product_completeness.py @@ -946,6 +946,19 @@ def test_sessions_list_json_filters_client_side(monkeypatch): assert payload["sessions"][0]["key"] == "a" +def test_sessions_list_rejects_negative_limit_before_gateway_call(monkeypatch): + fake = _install_fake_gateway(monkeypatch) + + result = runner.invoke(app, ["sessions", "list", "--limit", "-1", "--json"]) + + assert result.exit_code == 2 + assert result.stdout == "" + payload = json.loads(result.stderr) + assert payload["error"]["code"] == "INVALID_REQUEST" + assert "--limit must be >= 1" in payload["error"]["message"] + assert fake.calls == [] + + def test_sessions_list_uses_active_profile_managed_gateway_runtime_port( tmp_path: Path, monkeypatch, @@ -1407,6 +1420,22 @@ def test_cron_run_yes_calls_existing_rpc(monkeypatch): assert ("cron.run", {"id": "job-1"}) in fake.calls +def test_cron_runs_rejects_negative_limit_before_gateway_call(monkeypatch): + fake = _install_fake_gateway(monkeypatch) + + result = runner.invoke( + app, + ["cron", "runs", "job-1", "--limit", "-1", "--json"], + ) + + assert result.exit_code == 2 + assert result.stdout == "" + payload = json.loads(result.stderr) + assert payload["error"]["code"] == "INVALID_REQUEST" + assert "--limit must be >= 1" in payload["error"]["message"] + assert fake.calls == [] + + def test_cron_commands_use_existing_rpc_payloads(monkeypatch): fake = _install_fake_gateway(monkeypatch) fake.rpc_payloads = { diff --git a/tests/test_gateway/test_sessions_list_contract_adapter.py b/tests/test_gateway/test_sessions_list_contract_adapter.py index b7d0933bd..948e20ab7 100644 --- a/tests/test_gateway/test_sessions_list_contract_adapter.py +++ b/tests/test_gateway/test_sessions_list_contract_adapter.py @@ -589,6 +589,26 @@ async def legacy_caller(method: str, params: dict[str, Any] | None) -> Any: assert isinstance(result["ts"], int) +@pytest.mark.asyncio +async def test_legacy_sessions_list_rejects_negative_limit_before_storage() -> None: + storage = _LegacyListStorage() + ctx = RpcContext( + conn_id="invalid-limit", + principal=Principal( + role="operator", + scopes=frozenset({"operator.admin"}), + is_owner=True, + authenticated=True, + ), + session_manager=SimpleNamespace(storage=storage), + ) + + with pytest.raises(ValueError, match=r"params\.limit must be >= 1"): + await _handle_sessions_list({"limit": -1}, ctx) + + assert storage.list_calls == [] + + @pytest.mark.parametrize( ("legacy_surface", "params", "expected_page_aliases"), [