488 lines
17 KiB
Python
488 lines
17 KiB
Python
"""End-to-end integration tests for policy CRUD lifecycle flows.
|
|
|
|
Covers multi-step scenarios and cross-cutting concerns that the
|
|
per-endpoint unit-style tests in ``test_default_policy_routes.py``
|
|
and ``test_session_policy_routes.py`` do not exercise:
|
|
|
|
- Full create → read → update → delete lifecycle in a single test
|
|
- Policy registry discovery (``GET /v1/policy-registry``)
|
|
- Cross-scope isolation (default vs. session policies)
|
|
- Enabled/disabled toggle round-trip
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.server.app import create_app
|
|
from omnigent.server.auth import LEVEL_EDIT
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
|
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
|
|
from tests.server.conftest import ControllableMockClient
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_HANDLER = "omnigent.policies.builtins.safety.ask_on_os_tools"
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _admin_headers(email: str = "admin@example.com") -> dict[str, str]:
|
|
"""Return request headers simulating an authenticated admin.
|
|
|
|
:param email: The user email to present.
|
|
:returns: Dict with ``X-Forwarded-Email`` header.
|
|
"""
|
|
return {"X-Forwarded-Email": email}
|
|
|
|
|
|
def _make_admin(db_uri: str, email: str = "admin@example.com") -> None:
|
|
"""Seed the permission store with an admin user.
|
|
|
|
:param db_uri: SQLite URI for the per-test database.
|
|
:param email: Admin email to create.
|
|
"""
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
perm_store.ensure_user(email, is_admin=True)
|
|
|
|
|
|
def _seed_session(db_uri: str, user_email: str = "admin@example.com") -> str:
|
|
"""Create a session and grant LEVEL_EDIT to the given user.
|
|
|
|
:param db_uri: SQLite URI for the per-test database.
|
|
:param user_email: User to grant edit access.
|
|
:returns: The conversation ID.
|
|
"""
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
conversation = conv_store.create_conversation()
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
perm_store.ensure_user(user_email)
|
|
perm_store.grant(user_email, conversation.id, LEVEL_EDIT)
|
|
return conversation.id
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture()
|
|
def auth_app(
|
|
runtime_init: None,
|
|
db_uri: str,
|
|
tmp_path: Path,
|
|
) -> FastAPI:
|
|
"""App with auth, permission, and policy stores enabled.
|
|
|
|
:param runtime_init: Fixture that initializes the runtime with a mock LLM.
|
|
:param db_uri: Per-test SQLite URI.
|
|
:param tmp_path: Pytest temp dir for artifacts.
|
|
:returns: A :class:`FastAPI` instance with auth and policy routes.
|
|
"""
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
return create_app(
|
|
agent_store=SqlAlchemyAgentStore(db_uri),
|
|
file_store=SqlAlchemyFileStore(db_uri),
|
|
conversation_store=SqlAlchemyConversationStore(db_uri),
|
|
artifact_store=artifact_store,
|
|
agent_cache=AgentCache(
|
|
artifact_store=artifact_store,
|
|
cache_dir=tmp_path / "cache",
|
|
),
|
|
policy_store=SqlAlchemyPolicyStore(db_uri),
|
|
permission_store=SqlAlchemyPermissionStore(db_uri),
|
|
auth_provider=UnifiedAuthProvider(source="header"),
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture()
|
|
async def auth_client(
|
|
auth_app: FastAPI,
|
|
mock_llm: ControllableMockClient,
|
|
tmp_path: Path,
|
|
) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""Async HTTP client wired to the auth-enabled app.
|
|
|
|
:param auth_app: FastAPI app with permission and policy stores.
|
|
:param mock_llm: Controllable mock LLM — released on teardown.
|
|
:param tmp_path: Pytest temp dir for the harness process manager.
|
|
:yields: A ready-to-use :class:`httpx.AsyncClient`.
|
|
"""
|
|
from omnigent.runtime import set_harness_process_manager
|
|
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
|
|
|
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
|
|
await pm.start()
|
|
set_harness_process_manager(pm)
|
|
|
|
transport = httpx.ASGITransport(app=auth_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
mock_llm.release_all()
|
|
set_harness_process_manager(None)
|
|
await pm.shutdown()
|
|
|
|
|
|
# ── Full lifecycle ───────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_default_policy_full_lifecycle(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Exercise the complete default-policy lifecycle in a single flow.
|
|
|
|
create -> get -> list -> update -> get (verify update) -> delete -> verify 404.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
# 1. Create
|
|
create_resp = await auth_client.post(
|
|
"/v1/policies",
|
|
json={"name": "lifecycle_policy", "type": "python", "handler": _HANDLER},
|
|
headers=headers,
|
|
)
|
|
assert create_resp.status_code == 200
|
|
policy = create_resp.json()
|
|
policy_id = policy["id"]
|
|
assert policy["name"] == "lifecycle_policy"
|
|
assert policy["enabled"] is True
|
|
|
|
# 2. Get — verify freshly created state
|
|
get_resp = await auth_client.get(f"/v1/policies/{policy_id}", headers=headers)
|
|
assert get_resp.status_code == 200
|
|
assert get_resp.json()["name"] == "lifecycle_policy"
|
|
assert get_resp.json()["enabled"] is True
|
|
|
|
# 3. List — appears in the full list
|
|
list_resp = await auth_client.get("/v1/policies", headers=headers)
|
|
assert list_resp.status_code == 200
|
|
ids = {p["id"] for p in list_resp.json()["data"]}
|
|
assert policy_id in ids
|
|
|
|
# 4. Update — rename and disable
|
|
patch_resp = await auth_client.patch(
|
|
f"/v1/policies/{policy_id}",
|
|
json={"name": "lifecycle_renamed", "enabled": False},
|
|
headers=headers,
|
|
)
|
|
assert patch_resp.status_code == 200
|
|
assert patch_resp.json()["name"] == "lifecycle_renamed"
|
|
assert patch_resp.json()["enabled"] is False
|
|
|
|
# 5. Get — verify the update persisted
|
|
get_resp2 = await auth_client.get(f"/v1/policies/{policy_id}", headers=headers)
|
|
assert get_resp2.status_code == 200
|
|
body = get_resp2.json()
|
|
assert body["name"] == "lifecycle_renamed"
|
|
assert body["enabled"] is False
|
|
assert body["updated_at"] is not None
|
|
|
|
# 6. Delete
|
|
del_resp = await auth_client.delete(f"/v1/policies/{policy_id}", headers=headers)
|
|
assert del_resp.status_code == 200
|
|
assert del_resp.json()["deleted"] is True
|
|
|
|
# 7. Verify gone
|
|
gone_resp = await auth_client.get(f"/v1/policies/{policy_id}", headers=headers)
|
|
assert gone_resp.status_code == 404
|
|
|
|
|
|
async def test_session_policy_full_lifecycle(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Exercise the complete session-policy lifecycle in a single flow.
|
|
|
|
create -> get -> list -> update -> get (verify update) -> delete -> verify 404.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
session_id = _seed_session(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
# 1. Create
|
|
create_resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies",
|
|
json={"name": "session_lifecycle", "type": "python", "handler": _HANDLER},
|
|
headers=headers,
|
|
)
|
|
assert create_resp.status_code == 200
|
|
policy = create_resp.json()
|
|
policy_id = policy["id"]
|
|
assert policy["source"] == "session"
|
|
assert policy["enabled"] is True
|
|
|
|
# 2. Get
|
|
get_resp = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/policies/{policy_id}", headers=headers
|
|
)
|
|
assert get_resp.status_code == 200
|
|
assert get_resp.json()["name"] == "session_lifecycle"
|
|
|
|
# 3. List — policy appears among session-scoped entries
|
|
list_resp = await auth_client.get(f"/v1/sessions/{session_id}/policies", headers=headers)
|
|
assert list_resp.status_code == 200
|
|
session_ids = {p["id"] for p in list_resp.json()["data"] if p["id"] is not None}
|
|
assert policy_id in session_ids
|
|
|
|
# 4. Update — disable
|
|
patch_resp = await auth_client.patch(
|
|
f"/v1/sessions/{session_id}/policies/{policy_id}",
|
|
json={"enabled": False},
|
|
headers=headers,
|
|
)
|
|
assert patch_resp.status_code == 200
|
|
assert patch_resp.json()["enabled"] is False
|
|
|
|
# 5. Get — verify update persisted
|
|
get_resp2 = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/policies/{policy_id}", headers=headers
|
|
)
|
|
assert get_resp2.status_code == 200
|
|
assert get_resp2.json()["enabled"] is False
|
|
assert get_resp2.json()["updated_at"] is not None
|
|
|
|
# 6. Delete
|
|
del_resp = await auth_client.delete(
|
|
f"/v1/sessions/{session_id}/policies/{policy_id}", headers=headers
|
|
)
|
|
assert del_resp.status_code == 200
|
|
assert del_resp.json()["deleted"] is True
|
|
|
|
# 7. Verify gone
|
|
gone_resp = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/policies/{policy_id}", headers=headers
|
|
)
|
|
assert gone_resp.status_code == 404
|
|
|
|
|
|
# ── Policy registry discovery ────────────────────────────────────────────────
|
|
|
|
|
|
async def test_policy_registry_returns_entries(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""GET /v1/policy-registry returns available policy callables with schemas.
|
|
|
|
The registry must contain at least the built-in safety policies,
|
|
each entry should have handler, kind, name, and description fields.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
resp = await auth_client.get("/v1/policy-registry", headers=_admin_headers())
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["object"] == "list"
|
|
assert len(body["data"]) > 0
|
|
|
|
# Each entry must have the expected shape.
|
|
for entry in body["data"]:
|
|
assert "handler" in entry
|
|
assert "kind" in entry
|
|
assert "name" in entry
|
|
assert "description" in entry
|
|
assert entry["kind"] in ("callable", "factory")
|
|
|
|
# The handler used in tests must be present in the registry.
|
|
handlers = {e["handler"] for e in body["data"]}
|
|
assert _HANDLER in handlers
|
|
|
|
|
|
async def test_policy_registry_handler_matches_create_allowlist(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""A handler from the registry can be used to create a policy.
|
|
|
|
Picks the first handler from the registry and creates a default
|
|
policy with it, confirming the registry and write API agree on
|
|
the allowlist.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
reg_resp = await auth_client.get("/v1/policy-registry", headers=headers)
|
|
assert reg_resp.status_code == 200, reg_resp.text
|
|
entries = reg_resp.json()["data"]
|
|
# Pick a callable (not factory) entry to avoid needing factory_params.
|
|
callable_entry = next((e for e in entries if e["kind"] == "callable"), None)
|
|
assert callable_entry is not None, (
|
|
f"No callable entry in registry; got kinds: {[e.get('kind') for e in entries]}"
|
|
)
|
|
|
|
create_resp = await auth_client.post(
|
|
"/v1/policies",
|
|
json={
|
|
"name": "from_registry",
|
|
"type": "python",
|
|
"handler": callable_entry["handler"],
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert create_resp.status_code == 200
|
|
assert create_resp.json()["handler"] == callable_entry["handler"]
|
|
|
|
|
|
# ── Cross-scope isolation ────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_default_policy_not_in_session_list(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""A default policy does not appear in a session's policy list.
|
|
|
|
The session policy list endpoint merges session-scoped and
|
|
spec-declared (admin) policies, but store-persisted default
|
|
policies (``session_id IS NULL``) must not leak into it.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
session_id = _seed_session(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
# Create a default (server-wide) policy.
|
|
await auth_client.post(
|
|
"/v1/policies",
|
|
json={"name": "global_only", "type": "python", "handler": _HANDLER},
|
|
headers=headers,
|
|
)
|
|
|
|
# Session policy list must not contain it.
|
|
session_resp = await auth_client.get(f"/v1/sessions/{session_id}/policies", headers=headers)
|
|
assert session_resp.status_code == 200
|
|
session_names = {p["name"] for p in session_resp.json()["data"] if p["source"] == "session"}
|
|
assert "global_only" not in session_names
|
|
|
|
|
|
async def test_session_policy_not_in_default_list(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""A session policy does not appear in the default policy list.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
session_id = _seed_session(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
# Create a session-scoped policy.
|
|
await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies",
|
|
json={"name": "session_only", "type": "python", "handler": _HANDLER},
|
|
headers=headers,
|
|
)
|
|
|
|
# Default policy list must not contain it.
|
|
default_resp = await auth_client.get("/v1/policies", headers=headers)
|
|
assert default_resp.status_code == 200
|
|
default_names = {p["name"] for p in default_resp.json()["data"]}
|
|
assert "session_only" not in default_names
|
|
|
|
|
|
async def test_two_sessions_have_independent_policies(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Policies created in one session are not visible in another.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
session_a = _seed_session(db_uri)
|
|
session_b = _seed_session(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
# Create a policy in session A.
|
|
await auth_client.post(
|
|
f"/v1/sessions/{session_a}/policies",
|
|
json={"name": "only_in_a", "type": "python", "handler": _HANDLER},
|
|
headers=headers,
|
|
)
|
|
|
|
# Session B must not see it.
|
|
resp_b = await auth_client.get(f"/v1/sessions/{session_b}/policies", headers=headers)
|
|
assert resp_b.status_code == 200
|
|
names_b = {p["name"] for p in resp_b.json()["data"] if p["source"] == "session"}
|
|
assert "only_in_a" not in names_b
|
|
|
|
|
|
# ── Enabled/disabled toggle ─────────────────────────────────────────────────
|
|
|
|
|
|
async def test_disable_and_reenable_default_policy(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Toggling enabled off and back on persists correctly.
|
|
|
|
:param auth_client: HTTP client backed by the auth-enabled app.
|
|
:param db_uri: Per-test SQLite URI.
|
|
"""
|
|
_make_admin(db_uri)
|
|
headers = _admin_headers()
|
|
|
|
create_resp = await auth_client.post(
|
|
"/v1/policies",
|
|
json={"name": "togglable", "type": "python", "handler": _HANDLER},
|
|
headers=headers,
|
|
)
|
|
policy_id = create_resp.json()["id"]
|
|
assert create_resp.json()["enabled"] is True
|
|
|
|
# Disable
|
|
await auth_client.patch(
|
|
f"/v1/policies/{policy_id}",
|
|
json={"enabled": False},
|
|
headers=headers,
|
|
)
|
|
get_resp = await auth_client.get(f"/v1/policies/{policy_id}", headers=headers)
|
|
assert get_resp.json()["enabled"] is False
|
|
|
|
# Verify it shows as disabled in the list
|
|
list_resp = await auth_client.get("/v1/policies", headers=headers)
|
|
assert list_resp.status_code == 200, list_resp.text
|
|
match = next((p for p in list_resp.json()["data"] if p["id"] == policy_id), None)
|
|
assert match is not None, f"Policy {policy_id} not found in list"
|
|
assert match["enabled"] is False
|
|
|
|
# Re-enable
|
|
await auth_client.patch(
|
|
f"/v1/policies/{policy_id}",
|
|
json={"enabled": True},
|
|
headers=headers,
|
|
)
|
|
get_resp2 = await auth_client.get(f"/v1/policies/{policy_id}", headers=headers)
|
|
assert get_resp2.json()["enabled"] is True
|