476 lines
15 KiB
Python
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
|