forked from molecule-ai/molecule-core
Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
579 lines
23 KiB
Python
579 lines
23 KiB
Python
"""Tests for the approval tool — polling path, timeout, errors, and WebSocket path."""
|
|
|
|
import asyncio
|
|
import importlib
|
|
import sys
|
|
from types import ModuleType
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers to load the approval module in isolation with injectable mocks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_approval(monkeypatch, *, platform_url="http://platform.test",
|
|
workspace_id="ws-test", poll_interval="0.01", timeout="1"):
|
|
"""Reload tools.approval with controlled env vars and httpx mock.
|
|
|
|
Uses monkeypatch.setitem so sys.modules is restored after each test,
|
|
preventing the real module from leaking into other test modules.
|
|
"""
|
|
monkeypatch.setenv("PLATFORM_URL", platform_url)
|
|
monkeypatch.setenv("WORKSPACE_ID", workspace_id)
|
|
monkeypatch.setenv("APPROVAL_POLL_INTERVAL", poll_interval)
|
|
monkeypatch.setenv("APPROVAL_TIMEOUT", timeout)
|
|
|
|
# Ensure langchain_core.tools is mocked (decorator must be a no-op)
|
|
if "langchain_core" not in sys.modules:
|
|
lc = ModuleType("langchain_core")
|
|
lc_tools = ModuleType("langchain_core.tools")
|
|
lc_tools.tool = lambda f: f
|
|
monkeypatch.setitem(sys.modules, "langchain_core", lc)
|
|
monkeypatch.setitem(sys.modules, "langchain_core.tools", lc_tools)
|
|
else:
|
|
monkeypatch.setattr(sys.modules["langchain_core.tools"], "tool", lambda f: f, raising=False)
|
|
|
|
import importlib.util as ilu
|
|
import os
|
|
spec = ilu.spec_from_file_location(
|
|
"builtin_tools.approval",
|
|
os.path.join(os.path.dirname(__file__), "..", "builtin_tools", "approval.py"),
|
|
)
|
|
mod = ilu.module_from_spec(spec)
|
|
# Use setitem so monkeypatch restores the original mock after the test
|
|
monkeypatch.setitem(sys.modules, "builtin_tools.approval", mod)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code, payload):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Polling path — happy paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPollingApproval:
|
|
|
|
def test_approval_granted(self, monkeypatch):
|
|
"""request_approval returns approved=True when platform grants it."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
call_count = {"n": 0}
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
assert url == "http://platform.test/workspaces/ws-test/approvals"
|
|
assert json == {"action": "deploy", "reason": "need to ship"}
|
|
return _FakeResponse(201, {"approval_id": "appr-1"})
|
|
|
|
async def get(self, url):
|
|
call_count["n"] += 1
|
|
return _FakeResponse(200, [
|
|
{"id": "appr-1", "status": "approved", "decided_by": "alice@example.com"}
|
|
])
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("deploy", "need to ship"))
|
|
|
|
assert result["approved"] is True
|
|
assert result["approval_id"] == "appr-1"
|
|
assert result["decided_by"] == "alice@example.com"
|
|
|
|
def test_approval_denied(self, monkeypatch):
|
|
"""request_approval returns approved=False when platform denies."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-2"})
|
|
|
|
async def get(self, url):
|
|
return _FakeResponse(200, [
|
|
{"id": "appr-2", "status": "denied", "decided_by": "bob@example.com"}
|
|
])
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("delete everything", "spring cleaning"))
|
|
|
|
assert result["approved"] is False
|
|
assert result["approval_id"] == "appr-2"
|
|
assert result["decided_by"] == "bob@example.com"
|
|
assert result.get("message") == "Denied by human"
|
|
|
|
def test_approval_pending_then_granted(self, monkeypatch):
|
|
"""Polls through pending state before receiving approved status."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
responses = [
|
|
[{"id": "appr-3", "status": "pending"}],
|
|
[{"id": "appr-3", "status": "pending"}],
|
|
[{"id": "appr-3", "status": "approved", "decided_by": "carol"}],
|
|
]
|
|
idx = {"i": 0}
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-3"})
|
|
|
|
async def get(self, url):
|
|
payload = responses[min(idx["i"], len(responses) - 1)]
|
|
idx["i"] += 1
|
|
return _FakeResponse(200, payload)
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("restart service", "memory leak"))
|
|
|
|
assert result["approved"] is True
|
|
assert result["approval_id"] == "appr-3"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Failure / edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestApprovalFailures:
|
|
|
|
def test_post_failure_returns_error(self, monkeypatch):
|
|
"""Returns error dict when the approval creation POST fails."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
return _FakeResponse(500, {})
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("explode", "YOLO"))
|
|
|
|
assert result["approved"] is False
|
|
assert "error" in result
|
|
assert "500" in result["error"]
|
|
|
|
def test_post_exception_returns_error(self, monkeypatch):
|
|
"""Returns error dict when POST raises a network exception."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
raise ConnectionError("platform unreachable")
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("crash", "chaos"))
|
|
|
|
assert result["approved"] is False
|
|
assert "error" in result
|
|
|
|
def test_timeout_returns_error(self, monkeypatch):
|
|
"""Returns error dict when approval times out before a decision."""
|
|
# timeout=0.05s so the test is fast but exercises the timeout branch
|
|
mod = _load_approval(monkeypatch, poll_interval="0.03", timeout="0.05")
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-timeout"})
|
|
|
|
async def get(self, url):
|
|
# Always return pending — never decide
|
|
return _FakeResponse(200, [{"id": "appr-timeout", "status": "pending"}])
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("hang forever", "testing timeout"))
|
|
|
|
assert result["approved"] is False
|
|
assert "error" in result or "approval_id" in result # timed out
|
|
# Key assertion: approval_id present and no "decided_by" (no human decided)
|
|
assert result.get("approval_id") == "appr-timeout"
|
|
assert "decided_by" not in result
|
|
|
|
def test_poll_http_error_is_swallowed(self, monkeypatch):
|
|
"""Transient GET failures during polling are swallowed; tool keeps retrying."""
|
|
mod = _load_approval(monkeypatch, poll_interval="0.01", timeout="0.5")
|
|
|
|
call_count = {"n": 0}
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-flaky"})
|
|
|
|
async def get(self, url):
|
|
call_count["n"] += 1
|
|
if call_count["n"] < 3:
|
|
raise ConnectionError("transient")
|
|
return _FakeResponse(200, [
|
|
{"id": "appr-flaky", "status": "approved", "decided_by": "dave"}
|
|
])
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("try again", "retry logic"))
|
|
|
|
assert result["approved"] is True
|
|
assert call_count["n"] >= 3
|
|
|
|
def test_unrelated_approvals_ignored(self, monkeypatch):
|
|
"""Other approval records in the list don't affect the current request."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
responses = iter([
|
|
# First poll: only unrelated records
|
|
[
|
|
{"id": "appr-other", "status": "approved", "decided_by": "eve"},
|
|
],
|
|
# Second poll: our approval is decided
|
|
[
|
|
{"id": "appr-other", "status": "approved", "decided_by": "eve"},
|
|
{"id": "appr-target", "status": "approved", "decided_by": "frank"},
|
|
],
|
|
])
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-target"})
|
|
|
|
async def get(self, url):
|
|
try:
|
|
return _FakeResponse(200, next(responses))
|
|
except StopIteration:
|
|
return _FakeResponse(200, [])
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("targeted action", "specific reason"))
|
|
|
|
assert result["approved"] is True
|
|
assert result["approval_id"] == "appr-target"
|
|
assert result["decided_by"] == "frank"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket path (new implementation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestWebSocketApproval:
|
|
"""Tests for the WebSocket-based notification path.
|
|
|
|
When APPROVAL_USE_WEBSOCKET=true (or websockets is available), the tool
|
|
should subscribe to the platform WebSocket and wait for an APPROVAL_DECIDED
|
|
event instead of polling.
|
|
"""
|
|
|
|
def test_websocket_path_granted(self, monkeypatch):
|
|
"""WebSocket path resolves immediately when APPROVAL_DECIDED event arrives."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
# Skip if the module hasn't been upgraded to WebSocket support yet
|
|
if not hasattr(mod, "request_approval_ws") and not getattr(mod, "APPROVAL_USE_WEBSOCKET", None):
|
|
pytest.skip("WebSocket path not yet implemented in approval.py — see Track 2")
|
|
|
|
# Mock websockets.connect — must be a sync callable returning an async ctx manager
|
|
import json
|
|
|
|
class FakeWSConn:
|
|
"""Async context manager that yields one APPROVAL_DECIDED message."""
|
|
async def __aenter__(self_inner):
|
|
return self_inner
|
|
async def __aexit__(self_inner, *a):
|
|
pass
|
|
def __aiter__(self_inner):
|
|
return self_inner
|
|
async def __anext__(self_inner):
|
|
return json.dumps({
|
|
"event": "APPROVAL_DECIDED",
|
|
"approval_id": "appr-ws-1",
|
|
"status": "approved",
|
|
"decided_by": "grace@example.com",
|
|
})
|
|
|
|
class FakeWSModule:
|
|
@staticmethod
|
|
def connect(url, additional_headers=None):
|
|
return FakeWSConn()
|
|
|
|
monkeypatch.setattr(mod, "websockets", FakeWSModule, raising=False)
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-ws-1"})
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
monkeypatch.setenv("APPROVAL_USE_WEBSOCKET", "true")
|
|
|
|
result = asyncio.run(mod.request_approval("ws action", "ws reason"))
|
|
|
|
assert result["approved"] is True
|
|
assert result["approval_id"] == "appr-ws-1"
|
|
assert result["decided_by"] == "grace@example.com"
|
|
|
|
def test_websocket_path_denied(self, monkeypatch):
|
|
"""WebSocket path resolves with denied when APPROVAL_DECIDED event says denied."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
if not hasattr(mod, "request_approval_ws") and not getattr(mod, "APPROVAL_USE_WEBSOCKET", None):
|
|
pytest.skip("WebSocket path not yet implemented in approval.py — see Track 2")
|
|
|
|
import json
|
|
|
|
class FakeWSConnDeny:
|
|
async def __aenter__(self_inner): return self_inner
|
|
async def __aexit__(self_inner, *a): pass
|
|
def __aiter__(self_inner): return self_inner
|
|
async def __anext__(self_inner):
|
|
return json.dumps({
|
|
"event": "APPROVAL_DECIDED",
|
|
"approval_id": "appr-ws-deny",
|
|
"status": "denied",
|
|
"decided_by": "heidi",
|
|
})
|
|
|
|
class FakeWSModule:
|
|
@staticmethod
|
|
def connect(url, additional_headers=None):
|
|
return FakeWSConnDeny()
|
|
|
|
monkeypatch.setattr(mod, "websockets", FakeWSModule, raising=False)
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-ws-deny"})
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
monkeypatch.setenv("APPROVAL_USE_WEBSOCKET", "true")
|
|
|
|
result = asyncio.run(mod.request_approval("dangerous delete", "cleanup"))
|
|
|
|
assert result["approved"] is False
|
|
assert result["approval_id"] == "appr-ws-deny"
|
|
|
|
def test_websocket_fallback_to_polling_on_import_error(self, monkeypatch):
|
|
"""Falls back to polling gracefully if websockets package is missing."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
if not hasattr(mod, "request_approval_ws") and not getattr(mod, "APPROVAL_USE_WEBSOCKET", None):
|
|
pytest.skip("WebSocket path not yet implemented in approval.py — see Track 2")
|
|
|
|
# Simulate websockets not installed
|
|
monkeypatch.setattr(mod, "websockets", None, raising=False)
|
|
monkeypatch.setenv("APPROVAL_USE_WEBSOCKET", "true")
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-fallback"})
|
|
async def get(self, url):
|
|
return _FakeResponse(200, [
|
|
{"id": "appr-fallback", "status": "approved", "decided_by": "ivan"}
|
|
])
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
|
|
result = asyncio.run(mod.request_approval("fallback test", "ws unavailable"))
|
|
|
|
assert result["approved"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 6: Module-level _USE_WEBSOCKET_DEFAULT env-var branches (lines 65, 67, 72-73, 78-79)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestApprovalModuleLevelWebsocketBranches:
|
|
|
|
def test_env_false_sets_use_websocket_false(self, monkeypatch):
|
|
"""Line 65: APPROVAL_USE_WEBSOCKET=false → _USE_WEBSOCKET_DEFAULT=False."""
|
|
monkeypatch.setenv("APPROVAL_USE_WEBSOCKET", "false")
|
|
mod = _load_approval(monkeypatch)
|
|
assert mod._USE_WEBSOCKET_DEFAULT is False
|
|
|
|
def test_env_true_sets_use_websocket_true(self, monkeypatch):
|
|
"""Line 67: APPROVAL_USE_WEBSOCKET=true → _USE_WEBSOCKET_DEFAULT=True."""
|
|
monkeypatch.setenv("APPROVAL_USE_WEBSOCKET", "true")
|
|
mod = _load_approval(monkeypatch)
|
|
assert mod._USE_WEBSOCKET_DEFAULT is True
|
|
|
|
def test_env_unset_websockets_installed_sets_true(self, monkeypatch):
|
|
"""Lines 72-73: no env var, websockets importable → _USE_WEBSOCKET_DEFAULT=True."""
|
|
monkeypatch.delenv("APPROVAL_USE_WEBSOCKET", raising=False)
|
|
# Inject a fake websockets module so import succeeds
|
|
fake_ws = ModuleType("websockets")
|
|
monkeypatch.setitem(sys.modules, "websockets", fake_ws)
|
|
mod = _load_approval(monkeypatch)
|
|
assert mod._USE_WEBSOCKET_DEFAULT is True
|
|
|
|
def test_env_unset_websockets_not_installed_sets_false(self, monkeypatch):
|
|
"""Lines 78-79: no env var, websockets not importable → _USE_WEBSOCKET_DEFAULT=False."""
|
|
monkeypatch.delenv("APPROVAL_USE_WEBSOCKET", raising=False)
|
|
# Remove websockets so import fails
|
|
monkeypatch.setitem(sys.modules, "websockets", None)
|
|
mod = _load_approval(monkeypatch)
|
|
assert mod._USE_WEBSOCKET_DEFAULT is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 6: WebSocket _wait_websocket — invalid JSON, wrong event type, wrong ID
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestWaitWebsocketEdgeCases:
|
|
|
|
def test_websocket_invalid_json_message_skipped(self, monkeypatch):
|
|
"""Lines 126-127: invalid JSON message in WebSocket → continue (skipped)."""
|
|
mod = _load_approval(monkeypatch)
|
|
|
|
if not getattr(mod, "APPROVAL_USE_WEBSOCKET", None):
|
|
pytest.skip("WebSocket path not yet implemented")
|
|
|
|
import json as _json
|
|
|
|
messages_iter = iter([
|
|
"not valid json {{{", # invalid JSON → continue
|
|
_json.dumps({ # valid but wrong event type → continue
|
|
"event": "SOME_OTHER_EVENT",
|
|
"approval_id": "appr-ws-edge",
|
|
}),
|
|
_json.dumps({ # right event but wrong ID → continue
|
|
"event": "APPROVAL_DECIDED",
|
|
"approval_id": "appr-different-id",
|
|
"status": "approved",
|
|
"decided_by": "alice",
|
|
}),
|
|
_json.dumps({ # matching message
|
|
"event": "APPROVAL_DECIDED",
|
|
"approval_id": "appr-ws-edge",
|
|
"status": "approved",
|
|
"decided_by": "alice",
|
|
}),
|
|
])
|
|
|
|
class FakeWSConn:
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
def __aiter__(self): return self
|
|
async def __anext__(self):
|
|
try:
|
|
return next(messages_iter)
|
|
except StopIteration:
|
|
raise StopAsyncIteration
|
|
|
|
class FakeWSModule:
|
|
@staticmethod
|
|
def connect(url, additional_headers=None):
|
|
return FakeWSConn()
|
|
|
|
monkeypatch.setattr(mod, "websockets", FakeWSModule, raising=False)
|
|
|
|
class FakeClient:
|
|
def __init__(self, timeout): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): pass
|
|
async def post(self, url, json):
|
|
return _FakeResponse(201, {"approval_id": "appr-ws-edge"})
|
|
|
|
monkeypatch.setattr(mod.httpx, "AsyncClient", FakeClient)
|
|
monkeypatch.setenv("APPROVAL_USE_WEBSOCKET", "true")
|
|
|
|
result = asyncio.run(mod.request_approval("edge case action", "testing edge cases"))
|
|
|
|
assert result["approved"] is True
|
|
assert result["approval_id"] == "appr-ws-edge"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gap 6: RBAC deny in request_approval (lines 215-224)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRequestApprovalRBACDeny:
|
|
|
|
def test_rbac_deny_returns_error(self, monkeypatch):
|
|
"""Lines 215-224: check_permission returns False → approved=False with RBAC error."""
|
|
import importlib.util as ilu
|
|
import os
|
|
|
|
monkeypatch.setenv("PLATFORM_URL", "http://platform.test")
|
|
monkeypatch.setenv("WORKSPACE_ID", "ws-test")
|
|
monkeypatch.setenv("APPROVAL_POLL_INTERVAL", "0.01")
|
|
monkeypatch.setenv("APPROVAL_TIMEOUT", "1")
|
|
|
|
# Ensure langchain_core.tools is mocked
|
|
if "langchain_core" not in sys.modules:
|
|
lc = ModuleType("langchain_core")
|
|
lc_tools = ModuleType("langchain_core.tools")
|
|
lc_tools.tool = lambda f: f
|
|
monkeypatch.setitem(sys.modules, "langchain_core", lc)
|
|
monkeypatch.setitem(sys.modules, "langchain_core.tools", lc_tools)
|
|
else:
|
|
monkeypatch.setattr(sys.modules["langchain_core.tools"], "tool", lambda f: f, raising=False)
|
|
|
|
# Build a mock tools.audit that denies the "approve" permission
|
|
mock_audit_mod = ModuleType("builtin_tools.audit")
|
|
mock_audit_mod.check_permission = MagicMock(return_value=False)
|
|
mock_audit_mod.get_workspace_roles = MagicMock(return_value=(["read-only"], {}))
|
|
mock_audit_mod.log_event = MagicMock(return_value="trace-rbac")
|
|
monkeypatch.setitem(sys.modules, "builtin_tools.audit", mock_audit_mod)
|
|
|
|
spec = ilu.spec_from_file_location(
|
|
"builtin_tools.approval",
|
|
os.path.join(os.path.dirname(__file__), "..", "builtin_tools", "approval.py"),
|
|
)
|
|
mod2 = ilu.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, "builtin_tools.approval", mod2)
|
|
spec.loader.exec_module(mod2)
|
|
|
|
result = asyncio.run(mod2.request_approval("destroy everything", "chaos"))
|
|
|
|
assert result["approved"] is False
|
|
assert "error" in result
|
|
assert "RBAC" in result["error"] or "approve" in result["error"]
|
|
mock_audit_mod.log_event.assert_called_once()
|