229 lines
8.4 KiB
Python
229 lines
8.4 KiB
Python
"""Tests for the combined permission helper in ``_auth_helpers``.
|
|
|
|
Focused on :func:`require_access_and_level`, which folds ``require_access``
|
|
and ``get_permission_level`` into a single resolution. The behaviour it must
|
|
preserve is the 403-vs-404 distinction, the admin bypass, sub-agent parent
|
|
delegation, and the user-vs-public displayed-level asymmetry — all exercised
|
|
here against real SQLite-backed stores (no mocks) so the resolution matches
|
|
production exactly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from omnigent.errors import ErrorCode, OmnigentError
|
|
from omnigent.server.auth import (
|
|
LEVEL_EDIT,
|
|
LEVEL_OWNER,
|
|
LEVEL_READ,
|
|
RESERVED_USER_PUBLIC,
|
|
)
|
|
from omnigent.server.routes._auth_helpers import require_access_and_level
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.permission_store.sqlalchemy_store import (
|
|
SqlAlchemyPermissionStore,
|
|
)
|
|
|
|
ALICE = "alice@test.com"
|
|
BOB = "bob@test.com"
|
|
|
|
|
|
@pytest.fixture()
|
|
def perm_store(db_uri: str) -> SqlAlchemyPermissionStore:
|
|
"""A fresh permission store on the per-test SQLite DB.
|
|
|
|
:param db_uri: Per-test SQLite URI from the root conftest.
|
|
:returns: A ready :class:`SqlAlchemyPermissionStore`.
|
|
"""
|
|
return SqlAlchemyPermissionStore(db_uri)
|
|
|
|
|
|
@pytest.fixture()
|
|
def conv_store(db_uri: str) -> SqlAlchemyConversationStore:
|
|
"""A fresh conversation store on the per-test SQLite DB.
|
|
|
|
:param db_uri: Per-test SQLite URI from the root conftest.
|
|
:returns: A ready :class:`SqlAlchemyConversationStore`.
|
|
"""
|
|
return SqlAlchemyConversationStore(db_uri)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_owner_gets_level_and_conversation(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""An owner is allowed and the fetched conversation is returned for reuse.
|
|
|
|
The returned ``conversation`` is what lets the snapshot skip its own
|
|
``get_conversation`` read — assert it is the same session, not ``None``.
|
|
"""
|
|
conv = conv_store.create_conversation()
|
|
perm_store.ensure_user(ALICE)
|
|
perm_store.grant(ALICE, conv.id, LEVEL_OWNER)
|
|
|
|
access = await require_access_and_level(ALICE, conv.id, LEVEL_READ, perm_store, conv_store)
|
|
|
|
assert access.level == LEVEL_OWNER, (
|
|
f"owner must report level {LEVEL_OWNER}, got {access.level}"
|
|
)
|
|
assert access.conversation is not None, (
|
|
"the conversation must be returned so the snapshot can reuse it"
|
|
)
|
|
assert access.conversation.id == conv.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_access_raises_404_not_403(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""Bob, with no grant on Alice's session, gets 404 — not a 403 oracle.
|
|
|
|
Returning 403 would confirm the session exists; 404 keeps existence
|
|
hidden from a user with no access at all.
|
|
"""
|
|
conv = conv_store.create_conversation()
|
|
perm_store.ensure_user(ALICE)
|
|
perm_store.ensure_user(BOB)
|
|
perm_store.grant(ALICE, conv.id, LEVEL_OWNER)
|
|
|
|
with pytest.raises(OmnigentError) as exc:
|
|
await require_access_and_level(BOB, conv.id, LEVEL_READ, perm_store, conv_store)
|
|
|
|
assert exc.value.code == ErrorCode.NOT_FOUND, f"no-access must be 404, got {exc.value.code}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insufficient_level_raises_403(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""A read-only user asking for edit gets 403 (has access, not enough)."""
|
|
conv = conv_store.create_conversation()
|
|
perm_store.ensure_user(BOB)
|
|
perm_store.grant(BOB, conv.id, LEVEL_READ)
|
|
|
|
with pytest.raises(OmnigentError) as exc:
|
|
await require_access_and_level(BOB, conv.id, LEVEL_EDIT, perm_store, conv_store)
|
|
|
|
assert exc.value.code == ErrorCode.FORBIDDEN, (
|
|
f"insufficient level must be 403, got {exc.value.code}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_allowed_and_bypasses_conversation_fetch(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""Admin is allowed at OWNER level and does not fetch the conversation.
|
|
|
|
Mirrors ``check_session_access``'s admin short-circuit: ``conversation``
|
|
is ``None`` (no lookup happened), and the level is ``LEVEL_OWNER``.
|
|
"""
|
|
conv = conv_store.create_conversation()
|
|
perm_store.ensure_user("root@test.com", is_admin=True)
|
|
|
|
access = await require_access_and_level(
|
|
"root@test.com", conv.id, LEVEL_OWNER, perm_store, conv_store
|
|
)
|
|
|
|
assert access.level == LEVEL_OWNER
|
|
assert access.conversation is None, (
|
|
"admin path must not fetch the conversation (it bypasses the lookup)"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_grant_allows_but_level_reports_user_grant(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""Access via a higher public grant; displayed level is the user's own.
|
|
|
|
The regression guard for the combined helper: a low user grant plus a
|
|
higher ``__public__`` grant must still report the user's own level
|
|
(matching ``get_permission_level``) while granting access via the
|
|
public grant (matching ``check_access``).
|
|
"""
|
|
conv = conv_store.create_conversation()
|
|
perm_store.ensure_user(ALICE)
|
|
perm_store.ensure_user(RESERVED_USER_PUBLIC)
|
|
perm_store.grant(ALICE, conv.id, LEVEL_READ) # user: read
|
|
perm_store.grant(RESERVED_USER_PUBLIC, conv.id, LEVEL_OWNER) # public: owner
|
|
|
|
access = await require_access_and_level(ALICE, conv.id, LEVEL_EDIT, perm_store, conv_store)
|
|
|
|
# Allowed (no raise) because the public grant satisfies EDIT ...
|
|
assert access.conversation is not None
|
|
assert access.conversation.id == conv.id, "must reuse the asked-for session"
|
|
# ... but the displayed level is Alice's own read grant, unchanged.
|
|
assert access.level == LEVEL_READ, (
|
|
f"displayed level must be the user's own read grant, got {access.level}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sub_agent_delegates_access_to_parent(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""A sub-agent session inherits access from its parent's grant.
|
|
|
|
The user has a grant on the parent only; access to the sub-agent must
|
|
be allowed via parent delegation, while the displayed level (a direct
|
|
lookup on the sub-agent) stays ``None`` — unchanged from today.
|
|
"""
|
|
parent = conv_store.create_conversation()
|
|
child = conv_store.create_conversation(
|
|
kind="sub_agent",
|
|
parent_conversation_id=parent.id,
|
|
sub_agent_name="summarizer",
|
|
)
|
|
perm_store.ensure_user(ALICE)
|
|
perm_store.grant(ALICE, parent.id, LEVEL_OWNER)
|
|
|
|
access = await require_access_and_level(ALICE, child.id, LEVEL_READ, perm_store, conv_store)
|
|
|
|
assert access.conversation is not None
|
|
assert access.conversation.id == child.id, "snapshot reuses the sub-agent row"
|
|
# Displayed level is the direct grant on the sub-agent (none granted).
|
|
assert access.level is None, "displayed level is the sub-agent's own grant, which is None here"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_permissions_disabled_returns_empty_access(
|
|
conv_store: SqlAlchemyConversationStore,
|
|
) -> None:
|
|
"""With no permission store, the helper is a no-op (level None, no fetch)."""
|
|
access = await require_access_and_level(None, "conv_whatever", LEVEL_READ, None, conv_store)
|
|
|
|
assert access.level is None
|
|
assert access.conversation is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthenticated_with_store_raises_401(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""An anonymous caller against an enabled store is rejected with 401."""
|
|
conv = conv_store.create_conversation()
|
|
|
|
with pytest.raises(OmnigentError) as exc:
|
|
await require_access_and_level(None, conv.id, LEVEL_READ, perm_store, conv_store)
|
|
|
|
assert exc.value.code == ErrorCode.UNAUTHORIZED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_conversation_raises_404(
|
|
perm_store: SqlAlchemyPermissionStore, conv_store: SqlAlchemyConversationStore
|
|
) -> None:
|
|
"""A non-admin asking for a conversation that does not exist gets 404."""
|
|
perm_store.ensure_user(ALICE)
|
|
|
|
with pytest.raises(OmnigentError) as exc:
|
|
await require_access_and_level(
|
|
ALICE, "conv_does_not_exist", LEVEL_READ, perm_store, conv_store
|
|
)
|
|
|
|
assert exc.value.code == ErrorCode.NOT_FOUND
|