Files
2026-07-13 13:12:33 +08:00

476 lines
15 KiB
Python

"""Integration tests for the proposals RPC handlers.
These tests build a fake home dir under tmp_path, seed it with one or
two synthetic proposals, and call each handler through the live
``opensquilla.gateway.rpc`` dispatcher to verify the full path:
parameter validation → library call → JSON-ready return.
LLM is not involved — proposals_lib is deterministic.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from opensquilla.skills import proposals_lib
_SAMPLE_SKILL_MD = """---
name: synth-rpc-pipeline
description: "Sample meta-skill used by RPC proposals integration tests"
kind: meta
meta_priority: 50
triggers:
- "synth rpc trigger"
provenance:
origin: opensquilla-user
composition:
steps:
- id: a
skill: summarize
with:
task: "{{ inputs.user_message }}"
---
"""
def _seed(home: Path) -> str:
return proposals_lib.write_proposal(
home,
_SAMPLE_SKILL_MD,
{"G1": {"passed": True}, "G2": {"passed": True}},
{"G3": {"passed": True}, "G4": {"passed": True}},
)["proposal_id"]
def _mark_auto_enabled(home: Path, pid: str) -> None:
gates_path = home / "proposals" / pid / "gates.json"
import json
gates = json.loads(gates_path.read_text())
gates["auto_enable"] = {
"status": "enabled",
"proposal_id": pid,
"risk_level": "low",
"max_risk": "low",
"triggered_by": "manual",
"enabled_at_ms": 123,
"details": {
"validation_profile": "static-safety-v2",
"reason": "ok",
"skills": ["summarize"],
"tools": [],
"reasons": [],
},
}
gates_path.write_text(json.dumps(gates))
@pytest.fixture
def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect ``default_opensquilla_home`` to a tmp dir so the RPC
layer reads / writes there without touching the real ~/.opensquilla."""
from opensquilla.gateway.auto_propose_bridge import reset_runtime_for_test
reset_runtime_for_test()
home = tmp_path / ".opensquilla"
home.mkdir()
# ``proposals_lib`` invokes ``default_opensquilla_home()`` through the
# RPC module; patch the import surface used by rpc_proposals.
monkeypatch.setattr(
"opensquilla.gateway.rpc_proposals.default_opensquilla_home",
lambda: home,
)
return home
def _make_ctx(skill_loader: object | None = None) -> object:
"""Minimal RpcContext stand-in for proposal handlers."""
class _Ctx:
scopes: list[str] = []
skill_loader = None
ctx = _Ctx()
ctx.skill_loader = skill_loader
return ctx
class _CountingLoader:
def __init__(self) -> None:
self.invalidations = 0
def invalidate_cache(self) -> None:
self.invalidations += 1
@pytest.mark.asyncio
async def test_pending_count_reflects_seeded_proposals(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import _handle_pending_count
out = await _handle_pending_count(None, _make_ctx())
assert out == {"count": 0}
_seed(_isolated_home)
_seed(_isolated_home)
out2 = await _handle_pending_count(None, _make_ctx())
assert out2 == {"count": 2}
@pytest.mark.asyncio
async def test_list_returns_proposal_rows(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import _handle_list
pid1 = _seed(_isolated_home)
pid2 = _seed(_isolated_home)
out = await _handle_list(None, _make_ctx())
ids = sorted(r["proposal_id"] for r in out["proposals"])
assert ids == sorted([pid1, pid2])
@pytest.mark.asyncio
async def test_show_happy_path(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import _handle_show
pid = _seed(_isolated_home)
_mark_auto_enabled(_isolated_home, pid)
out = await _handle_show({"proposal_id": pid}, _make_ctx())
assert out["status"] == "ok"
assert out["proposal_id"] == pid
assert "synth-rpc-pipeline" in out["skill_md"]
assert out["auto_enable_audit"] == {
"status": "enabled",
"reason": "ok",
"risk_level": "low",
"max_risk": "low",
"validation_profile": "static-safety-v2",
"skills": ["summarize"],
"tools": [],
"reasons": [],
}
@pytest.mark.asyncio
async def test_show_camelcase_proposal_id_also_accepted(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.rpc_proposals import _handle_show
pid = _seed(_isolated_home)
out = await _handle_show({"proposalId": pid}, _make_ctx())
assert out["status"] == "ok"
@pytest.mark.asyncio
async def test_invalid_proposal_id_raises_value_error(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import _handle_show
with pytest.raises(ValueError):
await _handle_show({"proposal_id": "../etc"}, _make_ctx())
with pytest.raises(ValueError):
await _handle_show({"proposal_id": "TOOLONGTOMATCH"}, _make_ctx())
with pytest.raises(ValueError):
await _handle_show(None, _make_ctx())
@pytest.mark.asyncio
async def test_accept_promotes_proposal(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import _handle_accept
loader = _CountingLoader()
pid = _seed(_isolated_home)
out = await _handle_accept({"proposal_id": pid}, _make_ctx(loader))
assert out["status"] == "ok"
assert out["name"] == "synth-rpc-pipeline"
assert (_isolated_home / "skills" / "synth-rpc-pipeline" / "SKILL.md").is_file()
assert not (_isolated_home / "proposals" / pid).exists()
assert loader.invalidations == 1
@pytest.mark.asyncio
async def test_accept_with_force_overrides_gates(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.rpc_proposals import _handle_accept
bad_pid = proposals_lib.write_proposal(
_isolated_home,
_SAMPLE_SKILL_MD,
{"G1": {"passed": False}, "G2": {"passed": True}},
{"G3": {"passed": True}, "G4": {"passed": True}},
)["proposal_id"]
soft = await _handle_accept({"proposal_id": bad_pid}, _make_ctx())
assert soft["status"] == "refused"
hard = await _handle_accept(
{"proposal_id": bad_pid, "force": True}, _make_ctx(),
)
assert hard["status"] == "ok"
@pytest.mark.asyncio
async def test_reject_removes_proposal(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import _handle_reject
pid = _seed(_isolated_home)
out = await _handle_reject({"proposal_id": pid}, _make_ctx())
assert out["status"] == "ok"
assert not (_isolated_home / "proposals" / pid).exists()
@pytest.mark.asyncio
async def test_rejecting_unknown_proposal_returns_error(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.rpc_proposals import _handle_reject
out = await _handle_reject({"proposal_id": "deadbeef"}, _make_ctx())
assert out["status"] == "error"
assert "not found" in out["reason"]
@pytest.mark.asyncio
async def test_auto_enabled_list_and_disable_round_trip(_isolated_home: Path) -> None:
from opensquilla.gateway.rpc_proposals import (
_handle_accept,
_handle_auto_enabled_disable,
_handle_auto_enabled_list,
)
pid = _seed(_isolated_home)
_mark_auto_enabled(_isolated_home, pid)
loader = _CountingLoader()
accepted = await _handle_accept({"proposal_id": pid}, _make_ctx(loader))
assert accepted["status"] == "ok"
assert loader.invalidations == 1
listed = await _handle_auto_enabled_list(None, _make_ctx())
assert listed["skills"][0]["name"] == "synth-rpc-pipeline"
assert listed["skills"][0]["proposal_id"] == pid
assert listed["skills"][0]["validation_profile"] == "static-safety-v2"
assert listed["skills"][0]["skills"] == ["summarize"]
disabled = await _handle_auto_enabled_disable(
{"name": "synth-rpc-pipeline"}, _make_ctx(loader),
)
assert disabled["status"] == "ok"
assert (_isolated_home / "proposals" / pid / "SKILL.md").is_file()
assert loader.invalidations == 2
@pytest.mark.asyncio
async def test_settings_get_returns_unavailable_when_runtime_not_registered(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.auto_propose_bridge import reset_runtime_for_test
from opensquilla.gateway.rpc_proposals import _handle_settings_get
reset_runtime_for_test()
out = await _handle_settings_get(None, _make_ctx())
assert out["available"] is False
assert out["enabled"] is False
@pytest.mark.asyncio
async def test_settings_get_and_set_full_round_trip(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.auto_propose_bridge import (
AutoProposeRuntime,
register_runtime,
reset_runtime_for_test,
)
from opensquilla.gateway.config import MetaSkillAutoProposeConfig
from opensquilla.gateway.rpc_proposals import (
_handle_settings_get,
_handle_settings_set,
)
from opensquilla.skills.proposals_lib import (
read_auto_propose_settings,
)
reset_runtime_for_test()
cfg = MetaSkillAutoProposeConfig()
register_events: list[str] = []
async def register_crons() -> None:
register_events.append("register")
async def pause_crons() -> None:
register_events.append("pause")
register_runtime(AutoProposeRuntime(
config=cfg,
home=_isolated_home,
register_crons=register_crons,
pause_crons=pause_crons,
))
out = await _handle_settings_get(None, _make_ctx())
assert out["available"] is True
assert out["enabled"] is False
assert out["auto_enable"] is False
assert out["auto_enable_max_risk"] == "low"
# Flip on
out = await _handle_settings_set(
{
"enabled": True,
"auto_enable": True,
"auto_enable_max_risk": "medium",
}, _make_ctx(),
)
assert out["status"] == "ok"
assert out["settings"]["enabled"] is True
assert out["settings"]["auto_enable"] is True
assert out["settings"]["auto_enable_max_risk"] == "medium"
assert register_events == ["register"]
# Persisted
assert read_auto_propose_settings(_isolated_home) == {
"enabled": True,
"on_dream_complete": False,
"auto_enable": True,
"auto_enable_max_risk": "medium",
}
# Flip off
register_events.clear()
out = await _handle_settings_set({"enabled": False}, _make_ctx())
assert out["settings"]["enabled"] is False
assert register_events == ["pause"]
assert read_auto_propose_settings(_isolated_home) == {
"enabled": False,
"on_dream_complete": False,
"auto_enable": True,
"auto_enable_max_risk": "medium",
}
@pytest.mark.asyncio
async def test_settings_set_partial_update_only_changes_supplied_keys(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.auto_propose_bridge import (
AutoProposeRuntime,
register_runtime,
reset_runtime_for_test,
)
from opensquilla.gateway.config import MetaSkillAutoProposeConfig
from opensquilla.gateway.rpc_proposals import _handle_settings_set
reset_runtime_for_test()
cfg = MetaSkillAutoProposeConfig(enabled=True, on_dream_complete=True)
register_runtime(AutoProposeRuntime(
config=cfg,
home=_isolated_home,
register_crons=lambda: _noop(),
pause_crons=lambda: _noop(),
))
# Only toggle dream; enabled must stay True (no register/pause event)
out = await _handle_settings_set(
{"on_dream_complete": False}, _make_ctx(),
)
assert out["status"] == "ok"
assert out["settings"]["enabled"] is True
assert out["settings"]["on_dream_complete"] is False
assert out["settings"]["auto_enable"] is False
@pytest.mark.asyncio
async def test_settings_set_rolls_back_when_scheduler_update_fails(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.auto_propose_bridge import (
AutoProposeRuntime,
register_runtime,
reset_runtime_for_test,
)
from opensquilla.gateway.config import MetaSkillAutoProposeConfig
from opensquilla.gateway.rpc_proposals import _handle_settings_set
from opensquilla.skills.proposals_lib import read_auto_propose_settings
reset_runtime_for_test()
cfg = MetaSkillAutoProposeConfig(enabled=False, on_dream_complete=False)
async def register_crons() -> None:
raise RuntimeError("scheduler unavailable")
register_runtime(AutoProposeRuntime(
config=cfg,
home=_isolated_home,
register_crons=register_crons,
pause_crons=lambda: _noop(),
))
out = await _handle_settings_set({"enabled": True}, _make_ctx())
assert out["status"] == "error"
assert "scheduler unavailable" in out["reason"]
assert cfg.enabled is False
assert read_auto_propose_settings(_isolated_home) == {}
async def _noop() -> None: # helper for partial-update test
return None
@pytest.mark.asyncio
async def test_settings_set_rejects_non_boolean_values(
_isolated_home: Path,
) -> None:
from opensquilla.gateway.auto_propose_bridge import (
AutoProposeRuntime,
register_runtime,
reset_runtime_for_test,
)
from opensquilla.gateway.config import MetaSkillAutoProposeConfig
from opensquilla.gateway.rpc_proposals import _handle_settings_set
reset_runtime_for_test()
register_runtime(AutoProposeRuntime(
config=MetaSkillAutoProposeConfig(),
home=_isolated_home,
register_crons=lambda: _noop(),
pause_crons=lambda: _noop(),
))
with pytest.raises(ValueError):
await _handle_settings_set({"enabled": "yes"}, _make_ctx())
with pytest.raises(ValueError):
await _handle_settings_set({"auto_enable": "yes"}, _make_ctx())
with pytest.raises(ValueError):
await _handle_settings_set({"auto_enable_max_risk": "dangerous"}, _make_ctx())
def test_proposal_read_methods_classified_under_operator_proposals_scope() -> None:
"""Architecture invariant: scope drift would crash boot, but assert
explicitly so the relationship between rpc_proposals.py and
scopes.PROPOSALS_SCOPE is captured by a failing test if either side
moves."""
from opensquilla.gateway.scopes import (
METHOD_SCOPES,
PROPOSALS_SCOPE,
)
for name in (
"exec.proposals.pending_count",
"exec.proposals.list",
"exec.proposals.show",
"exec.proposals.settings.get",
"exec.proposals.auto_enabled.list",
):
assert METHOD_SCOPES.get(name) == PROPOSALS_SCOPE, name
def test_proposal_mutation_methods_require_admin_scope() -> None:
"""Proposal promotion changes the managed skill layer, so remote
no-auth operators must not be able to perform these mutations."""
from opensquilla.gateway.scopes import (
ADMIN_SCOPE,
METHOD_SCOPES,
)
for name in (
"exec.proposals.accept",
"exec.proposals.reject",
"exec.proposals.settings.set",
"exec.proposals.auto_enabled.disable",
):
assert METHOD_SCOPES.get(name) == ADMIN_SCOPE, name