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

201 lines
7.3 KiB
Python

"""OIDC integration tests for the global (default) policies routes.
The default-policies router (``/v1/policies``) gates purely on the
mode-agnostic ``permission_store.is_admin`` — reads require auth, writes
require admin — so it works under OIDC exactly as under accounts. These
tests pin that end-to-end: a real ``create_app`` wired with an OIDC
provider + permission store + policy store, driven by presenting an OIDC
session JWT as a Bearer token (the same mechanism ``test_oidc_admin_users``
uses).
Unlike Members (read-only under OIDC — no password actions), Policies is
fully functional: an OIDC admin can list, create, toggle, and delete.
"""
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 UnifiedAuthProvider
from omnigent.server.oidc import OIDCConfig, mint_session_cookie
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
from omnigent.stores.artifact_store.local import LocalArtifactStore
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
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
pytestmark = pytest.mark.asyncio
_TEST_SECRET = bytes.fromhex("aa" * 32)
_ADMIN = "admin@example.com"
_MEMBER = "member@example.com"
def _oidc_config() -> OIDCConfig:
"""Build a minimal GitHub-flavoured OIDCConfig for testing."""
return OIDCConfig(
issuer="https://github.com",
client_id="cid",
client_secret="secret",
redirect_uri="http://localhost:8000/auth/callback",
cookie_secret=_TEST_SECRET,
scopes="read:user user:email",
session_ttl_hours=8,
logout_redirect_uri=None,
allowed_domains=None,
provider_type="github",
authorization_endpoint="https://github.com/login/oauth/authorize",
token_endpoint="https://github.com/login/oauth/access_token",
jwks_uri=None,
userinfo_endpoint="https://api.github.com/user",
allow_invites=False,
)
def _bearer(user_id: str) -> dict[str, str]:
"""Authorization header carrying an OIDC session JWT for *user_id*."""
token = mint_session_cookie(
user_id=user_id,
cookie_secret=_TEST_SECRET,
ttl_hours=8,
provider="oidc",
)
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def oidc_policy_app(runtime_init: None, db_uri: str, tmp_path: Path) -> FastAPI:
"""A create_app instance with OIDC auth + permission store + policy store.
Seeds an admin and a non-admin so the tests can drive both gating
paths. Header/accounts routers aren't mounted (no login_url match
needed) — the /v1/policies router only needs auth_provider +
permission_store, which is exactly the OIDC wiring.
"""
perm_store = SqlAlchemyPermissionStore(db_uri)
perm_store.ensure_user(_ADMIN, is_admin=True)
perm_store.ensure_user(_MEMBER)
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
auth_provider = UnifiedAuthProvider(source="oidc", oidc_config=_oidc_config())
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",
),
comment_store=SqlAlchemyCommentStore(db_uri),
policy_store=SqlAlchemyPolicyStore(db_uri),
permission_store=perm_store,
auth_provider=auth_provider,
)
@pytest_asyncio.fixture()
async def oidc_policy_client(
oidc_policy_app: FastAPI,
) -> AsyncIterator[httpx.AsyncClient]:
"""HTTP client wired to the OIDC policy-enabled app."""
transport = httpx.ASGITransport(app=oidc_policy_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
def _policy_payload(**overrides: object) -> dict:
"""Build a valid CreateDefaultPolicyRequest payload."""
base: dict = {
"name": "oidc_policy",
"type": "python",
"handler": "omnigent.policies.builtins.safety.ask_on_os_tools",
}
base.update(overrides) # type: ignore[arg-type]
return base
# ── Admin: full CRUD works under OIDC ─────────────────────────────────
async def test_oidc_admin_can_crud_default_policies(
oidc_policy_client: httpx.AsyncClient,
) -> None:
"""An OIDC admin can create, list, toggle, and delete global policies."""
headers = _bearer(_ADMIN)
# Create.
create = await oidc_policy_client.post("/v1/policies", json=_policy_payload(), headers=headers)
assert create.status_code == 200, create.text
pid = create.json()["id"]
assert pid.startswith("pol_")
# List — the created policy is present.
listing = await oidc_policy_client.get("/v1/policies", headers=headers)
assert listing.status_code == 200
assert pid in [p["id"] for p in listing.json()["data"]]
# Toggle disabled.
patch = await oidc_policy_client.patch(
f"/v1/policies/{pid}", json={"enabled": False}, headers=headers
)
assert patch.status_code == 200
assert patch.json()["enabled"] is False
# Delete.
delete = await oidc_policy_client.delete(f"/v1/policies/{pid}", headers=headers)
assert delete.status_code == 200
assert delete.json()["deleted"] is True
# ── Auth gating ───────────────────────────────────────────────────────
async def test_oidc_unauthenticated_cannot_read_policies(
oidc_policy_client: httpx.AsyncClient,
) -> None:
"""A request with no session is rejected (401) — reads require auth."""
resp = await oidc_policy_client.get("/v1/policies")
assert resp.status_code == 401
async def test_oidc_non_admin_can_read_but_not_write(
oidc_policy_client: httpx.AsyncClient,
) -> None:
"""A non-admin OIDC user can list policies but cannot create them (403)."""
member = _bearer(_MEMBER)
# Reads are allowed for any authenticated user.
listing = await oidc_policy_client.get("/v1/policies", headers=member)
assert listing.status_code == 200
# Writes require admin — 403 for a member.
create = await oidc_policy_client.post("/v1/policies", json=_policy_payload(), headers=member)
assert create.status_code == 403
async def test_oidc_non_admin_cannot_delete(
oidc_policy_client: httpx.AsyncClient,
) -> None:
"""A non-admin can't delete a policy an admin created (403)."""
pid = (
await oidc_policy_client.post(
"/v1/policies", json=_policy_payload(), headers=_bearer(_ADMIN)
)
).json()["id"]
resp = await oidc_policy_client.delete(f"/v1/policies/{pid}", headers=_bearer(_MEMBER))
assert resp.status_code == 403