Files
Octop/tests/unit/knowledge/test_knowledge_hint.py
jubaoliangandjubaoliang 041628cb76 feat: per-user module permissions (RBAC) with admin bypass (#276)
* feat: add per-user module permissions (RBAC) with admin bypass

Introduce a module-level permission system so non-admin users can be
scoped to a subset of dashboard modules. Adds a `permissions` TEXT column
on the `users` table (migration 006, SQLite + PostgreSQL), a central
permission catalog in `infra/users/permissions.py`, backend ACL checks
across routers, and dashboard gating via `RequirePermission` /
`ForbiddenPage` keyed by nav module. `admin` always bypasses every gate;
read access and chat/agent use are never gated.

* fix: gate connector install-cli by connectors permission (not admin-only)

The host CLI installer is a connectors-module operation and should be
gated by the `connectors` module permission, consistent with the rest of
the connector routes, instead of being admin-only.

The earlier RBAC change demoted this route from `current_admin` but the
test `test_install_cli_forbidden_for_non_admin` still failed because the
`create_user` test helper grants `BASELINE_PERMISSIONS` (which includes
`connectors`) by default, so the "non-admin" user actually held the
permission. Gate the route with `require_permission("connectors")` and
create the test user with `permissions=[]` so it genuinely lacks the
key and is rejected with 403.

---------

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
2026-08-14 14:05:37 +08:00

139 lines
4.3 KiB
Python

"""Unit tests for knowledge-base tool description enrichment."""
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Annotated
from unittest.mock import MagicMock
from langchain.agents.middleware import ModelRequest
from langchain_core.tools import StructuredTool
from langgraph.config import var_child_runnable_config
from pydantic import Field
from octop.infra.knowledge.hint import (
KnowledgeSearchHintMiddleware,
catalog_for_selected_bases,
)
from octop.infra.knowledge.tools import format_search_knowledge_description
@contextmanager
def _configurable(**kwargs: object):
token = var_child_runnable_config.set({"configurable": kwargs})
try:
yield
finally:
var_child_runnable_config.reset(token)
def _dummy_tool(description: str = "base") -> StructuredTool:
async def search_knowledge(
query: Annotated[str, Field(description="q")],
) -> str:
return query
return StructuredTool.from_function(
coroutine=search_knowledge,
name="search_knowledge",
description=description,
)
def test_catalog_for_selected_bases_includes_name_and_description() -> None:
bases = [
SimpleNamespace(id="kb-1", name="Refund policy", description="Retail refund rules"),
SimpleNamespace(id="kb-2", name="", description=""),
]
assert catalog_for_selected_bases(bases, ["kb-1", "kb-2", "missing"]) == [
{"id": "kb-1", "name": "Refund policy", "description": "Retail refund rules"},
{"id": "kb-2", "name": "kb-2", "description": ""},
]
def test_format_description_lists_titles_and_descriptions() -> None:
text = format_search_knowledge_description(
[
{"id": "kb-1", "name": "Refund policy", "description": "Retail refund rules"},
{"id": "kb-2", "name": "HR handbook", "description": ""},
]
)
assert "Attached this turn:" in text
assert "- Refund policy: Retail refund rules" in text
assert "- HR handbook" in text
assert "overlaps any of those topics" in text
def test_format_description_clips_long_fields() -> None:
text = format_search_knowledge_description(
[{"id": "kb-1", "name": "Policy", "description": "x" * 400}]
)
line = next(part for part in text.splitlines() if part.startswith("- Policy:"))
assert line.endswith("...")
assert len(line) < 260
def test_format_description_none_selected() -> None:
text = format_search_knowledge_description([])
assert text == "No knowledge bases are attached this turn."
def test_middleware_rewrites_search_knowledge_description() -> None:
other = StructuredTool.from_function(
func=lambda: "x",
name="other",
description="keep",
)
request = ModelRequest(
model=MagicMock(),
messages=[],
tools=[_dummy_tool("stale"), other],
)
captured: list[ModelRequest] = []
def handler(req: ModelRequest) -> object:
captured.append(req)
return object()
with _configurable(
knowledge_base_catalog=[
{
"id": "kb-1",
"name": "Refund policy",
"description": "Retail refund rules",
}
],
):
KnowledgeSearchHintMiddleware().wrap_model_call(request, handler)
tools = list(captured[0].tools or [])
kb_tool = next(t for t in tools if getattr(t, "name", None) == "search_knowledge")
assert "Refund policy: Retail refund rules" in kb_tool.description
assert next(t for t in tools if getattr(t, "name", None) == "other").description == "keep"
assert request.tools[0].description == "stale"
def test_middleware_hides_tool_when_catalog_empty() -> None:
other = StructuredTool.from_function(
func=lambda: "x",
name="other",
description="keep",
)
request = ModelRequest(
model=MagicMock(),
messages=[],
tools=[_dummy_tool("stale"), other],
)
captured: list[ModelRequest] = []
def handler(req: ModelRequest) -> object:
captured.append(req)
return object()
with _configurable(knowledge_base_catalog=[]):
KnowledgeSearchHintMiddleware().wrap_model_call(request, handler)
names = [getattr(t, "name", None) for t in (captured[0].tools or [])]
assert names == ["other"]