mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 18:15:02 +00:00
Roles audit and revamp
This commit is contained in:
1 parent
208d34abc7
commit
98afa5d93c
134 files changed
+13725
-1702
No files matched your search
@@ -0,0 +1,118 @@
|
||||
"""Prompt and wiki-tool access in the answer pipeline (live grant checks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
OWNER = "alice"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_conn(monkeypatch, pg_conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield pg_conn
|
||||
|
||||
monkeypatch.setattr("docsgpt.api.answer.services.stream_processor.db_readonly", _yield)
|
||||
return pg_conn
|
||||
|
||||
|
||||
def _share(conn, rtype, rid, member, level="viewer"):
|
||||
team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
|
||||
TeamMembersRepository(conn).add_member(str(team["id"]), member)
|
||||
TeamResourceGrantsRepository(conn).grant(str(team["id"]), rtype, str(rid), OWNER, OWNER, access_level=level)
|
||||
return team
|
||||
|
||||
|
||||
def _processor(data, caller):
|
||||
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
||||
|
||||
return StreamProcessor(data, {"sub": caller})
|
||||
|
||||
|
||||
class TestAgentlessPrompt:
|
||||
def test_own_prompt_kept(self, use_conn):
|
||||
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
|
||||
proc = _processor({"question": "q", "prompt_id": pid}, OWNER)
|
||||
proc._configure_agent()
|
||||
assert proc.agent_config["prompt_id"] == pid
|
||||
|
||||
def test_shared_prompt_kept_then_falls_back_when_revoked(self, use_conn):
|
||||
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
|
||||
team = _share(use_conn, "prompt", pid, "bob")
|
||||
proc = _processor({"question": "q", "prompt_id": pid}, "bob")
|
||||
proc._configure_agent()
|
||||
assert proc.agent_config["prompt_id"] == pid
|
||||
TeamResourceGrantsRepository(use_conn).revoke(str(team["id"]), "prompt", pid)
|
||||
proc = _processor({"question": "q", "prompt_id": pid}, "bob")
|
||||
proc._configure_agent()
|
||||
assert proc.agent_config["prompt_id"] == "default"
|
||||
|
||||
def test_strangers_prompt_falls_back_to_default(self, use_conn):
|
||||
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "secret")["id"])
|
||||
proc = _processor({"question": "q", "prompt_id": pid}, "eve")
|
||||
proc._configure_agent()
|
||||
assert proc.agent_config["prompt_id"] == "default"
|
||||
|
||||
def test_presets_pass_through_without_db(self):
|
||||
proc = _processor({"question": "q", "prompt_id": "creative"}, "eve")
|
||||
proc._configure_agent()
|
||||
assert proc.agent_config["prompt_id"] == "creative"
|
||||
|
||||
|
||||
class TestAgentPrompt:
|
||||
def _run(self, monkeypatch, agent_owner, prompt_id, caller="carol"):
|
||||
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
||||
|
||||
monkeypatch.setattr(
|
||||
StreamProcessor, "_get_data_from_api_key",
|
||||
lambda self, key: {"prompt_id": prompt_id, "user": agent_owner, "_id": None},
|
||||
)
|
||||
proc = _processor({"question": "q", "api_key": "k"}, caller)
|
||||
proc._configure_agent()
|
||||
return proc.agent_config["prompt_id"]
|
||||
|
||||
def test_prompt_checked_against_agent_owner(self, use_conn, monkeypatch):
|
||||
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
|
||||
# Bob's agent uses Alice's prompt: allowed only while Bob has ``use``.
|
||||
assert self._run(monkeypatch, "bob", pid) == "default"
|
||||
_share(use_conn, "prompt", pid, "bob")
|
||||
assert self._run(monkeypatch, "bob", pid) == pid
|
||||
assert self._run(monkeypatch, OWNER, pid) == pid
|
||||
|
||||
def test_deleted_prompt_falls_back(self, use_conn, monkeypatch):
|
||||
assert self._run(monkeypatch, OWNER, str(uuid.uuid4())) == "default"
|
||||
|
||||
|
||||
class TestWikiConfigAccess:
|
||||
def _wiki(self, conn):
|
||||
return SourcesRepository(conn).create("W", user_id=OWNER, config={"kind": "wiki"})
|
||||
|
||||
def _cfg(self, conn, caller, sid):
|
||||
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
||||
|
||||
proc = StreamProcessor.__new__(StreamProcessor)
|
||||
proc.all_sources = [{"id": sid}]
|
||||
proc.decoded_token = {"sub": caller}
|
||||
return proc._build_wiki_config()
|
||||
|
||||
def test_owner_and_editor_get_tool_viewer_does_not(self, use_conn):
|
||||
sid = str(self._wiki(use_conn)["id"])
|
||||
assert self._cfg(use_conn, OWNER, sid)["source_owner_id"] == OWNER
|
||||
_share(use_conn, "source", sid, "ed", "editor")
|
||||
_share(use_conn, "source", sid, "vi", "viewer")
|
||||
cfg = self._cfg(use_conn, "ed", sid)
|
||||
assert cfg["source_owner_id"] == OWNER and cfg["user"] == "ed"
|
||||
assert self._cfg(use_conn, "vi", sid) is None
|
||||
assert self._cfg(use_conn, "eve", sid) is None
|
||||
@@ -27,6 +27,34 @@ def _cm(value):
|
||||
yield value
|
||||
|
||||
|
||||
def _access_patches(sub, repo, team_access):
|
||||
"""Stand in for ``resource_access``: owner when the repo owns the row,
|
||||
the team level when given, else not visible."""
|
||||
from docsgpt.api.user.resource_access import AccessDenied, build
|
||||
|
||||
def _resolve(_conn, resource_type, resource_id, user_id):
|
||||
if repo.get_any.return_value:
|
||||
return build(resource_type, resource_id, "owner", user_id, {})
|
||||
if team_access:
|
||||
return build(resource_type, resource_id, team_access, "owner-x", {})
|
||||
return None
|
||||
|
||||
def _require(conn, resource_type, resource_id, user_id, action):
|
||||
ra = _resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None:
|
||||
raise AccessDenied(404, "Agent not found")
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
return ra
|
||||
|
||||
if repo.get_any.return_value and isinstance(repo.get_by_id.return_value, Mock):
|
||||
repo.get_by_id.return_value = repo.get_any.return_value
|
||||
return [
|
||||
patch("docsgpt.api.user.agents.routes.resolve", _resolve),
|
||||
patch("docsgpt.api.user.agents.routes.require", _require),
|
||||
]
|
||||
|
||||
|
||||
def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_details=None):
|
||||
if source_details is None:
|
||||
source_details = []
|
||||
@@ -35,7 +63,7 @@ def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_de
|
||||
patch("docsgpt.app.resolve_roles", return_value=["user"]),
|
||||
patch("docsgpt.api.user.agents.routes.db_readonly", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
|
||||
patch("docsgpt.api.user.agents.routes.team_access_for", return_value=team_access),
|
||||
*_access_patches(sub, repo, team_access),
|
||||
# Resolve names by id (owner-agnostic) — patched so the test never
|
||||
# touches the DB; the route is what we're asserting wires them in.
|
||||
patch(
|
||||
@@ -70,7 +98,7 @@ class TestGetAgentTeamFallback:
|
||||
data = json.loads(resp.data)
|
||||
assert data["ownership"] == "user"
|
||||
assert data["team_access"] is None
|
||||
repo.get_by_id.assert_not_called()
|
||||
assert data["access"] == "owner"
|
||||
|
||||
def test_team_member_sees_shared_agent(self, client):
|
||||
aid = str(uuid.uuid4())
|
||||
@@ -92,7 +120,8 @@ class TestGetAgentTeamFallback:
|
||||
# Owner's public share token / API key are blanked for grantees.
|
||||
assert data["shared_token"] == ""
|
||||
assert data.get("key", "") == ""
|
||||
# get_by_id only reached AFTER the team grant check.
|
||||
assert data["access"] == "viewer"
|
||||
# get_by_id only reached AFTER the access check.
|
||||
repo.get_by_id.assert_called_once_with(aid)
|
||||
|
||||
def test_no_access_returns_404(self, client):
|
||||
@@ -164,7 +193,7 @@ def _update_patches(sub, repo, team_access, can_access_mock):
|
||||
patch("docsgpt.app.resolve_roles", return_value=["user"]),
|
||||
patch("docsgpt.api.user.agents.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
|
||||
patch("docsgpt.api.user.agents.routes.team_access_for", return_value=team_access),
|
||||
*_access_patches(sub, repo, team_access),
|
||||
patch("docsgpt.api.user.agents.routes.can_access", can_access_mock),
|
||||
]
|
||||
|
||||
|
||||
@@ -484,17 +484,35 @@ class TestConnectorSync:
|
||||
r = ConnectorSync().post()
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_returns_400_missing_fields(self, app):
|
||||
def test_returns_400_missing_source_id(self, app):
|
||||
from docsgpt.api.connector.routes import ConnectorSync
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/connectors/sync", method="POST", json={"source_id": "x"}
|
||||
"/api/connectors/sync", method="POST", json={"session_token": "y"}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
r = ConnectorSync().post()
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_owner_without_session_token_returns_400(self, app, pg_conn):
|
||||
# The owner syncs with their own session token; team editors don't
|
||||
# send one (their sync uses the owner's session).
|
||||
from docsgpt.api.connector.routes import ConnectorSync
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
user = "u-sync-notoken"
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"s", user_id=user, remote_data={"provider": "github"}
|
||||
)
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
"/api/connectors/sync", method="POST", json={"source_id": str(src["id"])}
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user}
|
||||
r = ConnectorSync().post()
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_returns_404_source_not_found(self, app, pg_conn):
|
||||
from docsgpt.api.connector.routes import ConnectorSync
|
||||
|
||||
|
||||
@@ -56,13 +56,16 @@ _CONVERSATIONS = "docsgpt.api.user.conversations.routes"
|
||||
@pytest.mark.unit
|
||||
class TestSourceAudit:
|
||||
def test_delete_records_source_deleted(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = {"id": "src-1", "name": "Handbook"}
|
||||
from docsgpt.api.user.resource_access import build
|
||||
|
||||
doc = {"id": "src-1", "name": "Handbook"}
|
||||
ra = build("source", "src-1", "owner", "u1", {})
|
||||
storage = Mock()
|
||||
storage.file_exists.return_value = False
|
||||
with _authed(
|
||||
_SOURCES,
|
||||
SourcesRepository=Mock(return_value=repo),
|
||||
load_source=Mock(return_value=(doc, ra)),
|
||||
SourcesRepository=Mock(return_value=Mock()),
|
||||
StorageCreator=Mock(get_storage=Mock(return_value=storage)),
|
||||
) as recorded:
|
||||
resp = client.get("/api/delete_old?source_id=src-1")
|
||||
@@ -72,9 +75,12 @@ class TestSourceAudit:
|
||||
assert recorded[0][3]["name"] == "Handbook"
|
||||
|
||||
def test_nothing_recorded_when_the_source_is_missing(self, client):
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = None
|
||||
with _authed(_SOURCES, SourcesRepository=Mock(return_value=repo)) as recorded:
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
|
||||
with _authed(
|
||||
_SOURCES,
|
||||
load_source=Mock(side_effect=AccessDenied(404, "Source not found")),
|
||||
) as recorded:
|
||||
resp = client.get("/api/delete_old?source_id=nope")
|
||||
assert resp.status_code == 404
|
||||
assert recorded == []
|
||||
@@ -158,8 +164,10 @@ class TestRemoteSourceAudit:
|
||||
@pytest.mark.unit
|
||||
class TestAgentAudit:
|
||||
def test_delete_records_agent_deleted(self, client):
|
||||
from docsgpt.api.user.resource_access import build
|
||||
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = {
|
||||
repo.get_by_id.return_value = {
|
||||
"id": "agent-1",
|
||||
"name": "Support bot",
|
||||
"agent_type": "classic",
|
||||
@@ -169,6 +177,8 @@ class TestAgentAudit:
|
||||
AgentsRepository=Mock(return_value=repo),
|
||||
WorkflowsRepository=Mock(),
|
||||
UsersRepository=Mock(),
|
||||
require=Mock(return_value=build("agent", "agent-1", "owner", "u1", {})),
|
||||
delete_settings=Mock(),
|
||||
) as recorded:
|
||||
resp = client.delete("/api/delete_agent?id=agent-1")
|
||||
assert resp.status_code == 200
|
||||
@@ -177,9 +187,14 @@ class TestAgentAudit:
|
||||
assert recorded[0][3]["name"] == "Support bot"
|
||||
|
||||
def test_missing_agent_records_nothing(self, client):
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
|
||||
repo = Mock()
|
||||
repo.get_any.return_value = None
|
||||
with _authed(_AGENTS, AgentsRepository=Mock(return_value=repo)) as recorded:
|
||||
with _authed(
|
||||
_AGENTS,
|
||||
AgentsRepository=Mock(return_value=repo),
|
||||
require=Mock(side_effect=AccessDenied(404, "Agent not found")),
|
||||
) as recorded:
|
||||
assert client.delete("/api/delete_agent?id=x").status_code == 404
|
||||
assert recorded == []
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These drive the real app.py chokepoint + team_authz plane (only handle_auth /
|
||||
resolve_roles and the leaf repos are mocked), so they catch regressions in the
|
||||
authorization wiring: who can read/manage a team, that team_id comes from the
|
||||
URL path (never the body), and that sharing requires resource ownership. Data
|
||||
URL path (never the body), and that sharing requires the ``share`` action. Data
|
||||
correctness lives in tests/storage/db/repositories/test_teams.py.
|
||||
"""
|
||||
|
||||
@@ -15,6 +15,11 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.api.user.resource_access import AccessDenied, build
|
||||
|
||||
# What ``require(..., "share")`` returns for the resource owner.
|
||||
_OWNER_RA = build("agent", "22222222-2222-2222-2222-222222222222", "owner", "alice", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
@@ -234,10 +239,13 @@ class TestTeamAccessControl:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSharingAuthz:
|
||||
def test_share_requires_ownership(self, client):
|
||||
def test_share_requires_share_action(self, client):
|
||||
patches = _auth(sub="bob", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=False),
|
||||
patch(
|
||||
"docsgpt.api.user.teams.routes.require",
|
||||
side_effect=AccessDenied(403, "no share"),
|
||||
),
|
||||
]
|
||||
_apply(patches)
|
||||
try:
|
||||
@@ -257,7 +265,7 @@ class TestSharingAuthz:
|
||||
grants_repo.grant.return_value = {"id": "g1", "access_level": "viewer"}
|
||||
patches = _auth(sub="alice", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
|
||||
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
|
||||
patch(
|
||||
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
|
||||
return_value=grants_repo,
|
||||
@@ -299,7 +307,7 @@ class TestSharingAuthz:
|
||||
members_repo.is_member.return_value = False
|
||||
patches = _auth(sub="alice", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
|
||||
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
|
||||
patch(
|
||||
"docsgpt.api.user.teams.routes.TeamMembersRepository",
|
||||
return_value=members_repo,
|
||||
@@ -326,7 +334,7 @@ class TestSharingAuthz:
|
||||
grants_repo.grant.return_value = {"id": "g1", "target_user_id": "bob"}
|
||||
patches = _auth(sub="alice", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
|
||||
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
|
||||
patch(
|
||||
"docsgpt.api.user.teams.routes.TeamMembersRepository",
|
||||
return_value=members_repo,
|
||||
@@ -357,7 +365,7 @@ class TestSharingAuthz:
|
||||
# rejected cleanly, not cast-and-poison the txn into a generic error.
|
||||
patches = _auth(sub="alice", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
|
||||
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
|
||||
]
|
||||
_apply(patches)
|
||||
try:
|
||||
@@ -480,7 +488,7 @@ class TestTeamNotifications:
|
||||
patches = _auth(sub="alice", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
|
||||
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
|
||||
patch(
|
||||
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
|
||||
return_value=grants_repo,
|
||||
@@ -531,7 +539,7 @@ class TestTeamNotifications:
|
||||
patches = _auth(sub="alice", team_role="team_member") + [
|
||||
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
|
||||
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
|
||||
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
|
||||
patch(
|
||||
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
|
||||
return_value=grants_repo,
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
"""Role-based access (owner / editor / viewer / stranger) on the agent routes.
|
||||
|
||||
Every per-agent endpoint answers through ``resource_access.require``: 404 when
|
||||
the caller can't see the agent, 403 when their role can't do the action, and
|
||||
writes land as the agent's owner. Uses real repositories on ``pg_conn``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from docsgpt.api.user.resource_access import set_settings, settings_for
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
OWNER, EDITOR, VIEWER, STRANGER = "r-owner", "r-editor", "r-viewer", "r-stranger"
|
||||
|
||||
_DB_MODULES = (
|
||||
"docsgpt.api.user.agents.routes",
|
||||
"docsgpt.api.user.agents.sharing",
|
||||
"docsgpt.api.user.agents.webhooks",
|
||||
"docsgpt.api.user.agents.guardrails",
|
||||
"docsgpt.api.user.agents.folders",
|
||||
"docsgpt.api.user.agents.portability",
|
||||
"docsgpt.api.user.schedules.routes",
|
||||
"docsgpt.api.user.workflows.routes",
|
||||
"docsgpt.api.user.analytics.routes",
|
||||
"docsgpt.api.user.base",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
import importlib
|
||||
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with ExitStack() as stack:
|
||||
for mod_name in _DB_MODULES:
|
||||
mod = importlib.import_module(mod_name)
|
||||
for attr in ("db_session", "db_readonly"):
|
||||
if hasattr(mod, attr):
|
||||
stack.enter_context(patch(f"{mod_name}.{attr}", _yield))
|
||||
yield
|
||||
|
||||
|
||||
def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()):
|
||||
kwargs = {"method": method.upper()}
|
||||
if json is not None:
|
||||
kwargs["json"] = json
|
||||
with _patch_db(conn), app.test_request_context(path, **kwargs):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user} if user else None
|
||||
return getattr(resource_cls(), method.lower())(*args)
|
||||
|
||||
|
||||
def _status(resp) -> int:
|
||||
return resp[1] if isinstance(resp, tuple) else resp.status_code
|
||||
|
||||
|
||||
def _json(resp):
|
||||
return resp[0] if isinstance(resp, tuple) else resp.get_json()
|
||||
|
||||
|
||||
def _team_share(conn, resource_type, resource_id, owner=OWNER):
|
||||
"""Share with a fresh team: EDITOR as editor, VIEWER as viewer."""
|
||||
team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", owner)
|
||||
tid = str(team["id"])
|
||||
members = TeamMembersRepository(conn)
|
||||
grants = TeamResourceGrantsRepository(conn)
|
||||
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
|
||||
members.add_member(tid, member)
|
||||
grants.grant(tid, resource_type, resource_id, owner, owner,
|
||||
access_level=level, target_user_id=member)
|
||||
return tid
|
||||
|
||||
|
||||
def _agent(conn, *, status="published", key="abcd1234wxyz", share=True, **extra):
|
||||
extra.setdefault("agent_type", "classic")
|
||||
extra.setdefault("chunks", 6)
|
||||
row = AgentsRepository(conn).create(
|
||||
OWNER, "Shared", status, description="d", key=key, **extra
|
||||
)
|
||||
agent_id = str(row["id"])
|
||||
if share:
|
||||
_team_share(conn, "agent", agent_id)
|
||||
return agent_id
|
||||
|
||||
|
||||
def _row(conn, agent_id):
|
||||
return AgentsRepository(conn).get_by_id(agent_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_agent / get_agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAgent:
|
||||
def _get(self, app, conn, agent_id, user):
|
||||
from docsgpt.api.user.agents.routes import GetAgent
|
||||
|
||||
return _call(app, conn, GetAgent, "get", f"/api/get_agent?id={agent_id}", user)
|
||||
|
||||
def test_owner_gets_owner_access_and_masked_key(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
data = _json(self._get(app, pg_conn, agent_id, OWNER))
|
||||
assert data["access"] == "owner"
|
||||
assert "delete" in data["allowed_actions"]
|
||||
assert data["key"] == "abcd...wxyz"
|
||||
|
||||
def test_editor_gets_editor_access_and_masked_key(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn, config={"guardrails": {"enabled": True}})
|
||||
resp = self._get(app, pg_conn, agent_id, EDITOR)
|
||||
assert _status(resp) == 200
|
||||
data = _json(resp)
|
||||
assert data["access"] == "editor"
|
||||
assert data["allowed_actions"] == sorted(data["allowed_actions"])
|
||||
assert "edit" in data["allowed_actions"]
|
||||
assert "delete" not in data["allowed_actions"]
|
||||
assert data["key"] == "abcd...wxyz"
|
||||
assert data["config"] == {"guardrails": {"enabled": True}}
|
||||
assert data["ownership"] == "team"
|
||||
|
||||
def test_editor_without_access_details_gets_no_key(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
set_settings(pg_conn, "agent", agent_id,
|
||||
{"editors_can_manage_access_details": False}, OWNER)
|
||||
data = _json(self._get(app, pg_conn, agent_id, EDITOR))
|
||||
assert "key" not in data or data["key"] == ""
|
||||
assert data["shared_token"] == ""
|
||||
|
||||
def test_viewer_reads_for_chat_without_secrets_or_policy(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn, config={"guardrails": {"enabled": True}})
|
||||
resp = self._get(app, pg_conn, agent_id, VIEWER)
|
||||
assert _status(resp) == 200
|
||||
data = _json(resp)
|
||||
assert data["access"] == "viewer"
|
||||
assert data["allowed_actions"] == ["pin", "use"]
|
||||
assert data.get("key", "") == ""
|
||||
assert data["config"] == {}
|
||||
|
||||
def test_stranger_404(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
assert _status(self._get(app, pg_conn, agent_id, STRANGER)) == 404
|
||||
|
||||
|
||||
class TestGetAgents:
|
||||
def test_rows_carry_access(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.routes import GetAgents
|
||||
|
||||
shared_id = _agent(pg_conn)
|
||||
own = AgentsRepository(pg_conn).create(EDITOR, "Mine", "draft")
|
||||
resp = _call(app, pg_conn, GetAgents, "get", "/api/get_agents", EDITOR)
|
||||
rows = {r["id"]: r for r in _json(resp)}
|
||||
assert rows[str(own["id"])]["access"] == "owner"
|
||||
assert "delete" in rows[str(own["id"])]["allowed_actions"]
|
||||
assert rows[shared_id]["access"] == "editor"
|
||||
assert "edit" in rows[shared_id]["allowed_actions"]
|
||||
assert rows[shared_id]["team_access"] == "editor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateAgent:
|
||||
def _put(self, app, conn, agent_id, user, body):
|
||||
from docsgpt.api.user.agents.routes import UpdateAgent
|
||||
|
||||
return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}",
|
||||
user, json=body, args=(agent_id,))
|
||||
|
||||
def test_editor_edits_as_owner(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR, {"name": "Renamed"})
|
||||
assert _status(resp) == 200
|
||||
row = _row(pg_conn, agent_id)
|
||||
assert row["name"] == "Renamed"
|
||||
assert row["user_id"] == OWNER
|
||||
|
||||
def test_viewer_403_stranger_404(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
assert _status(self._put(app, pg_conn, agent_id, VIEWER, {"name": "x"})) == 403
|
||||
assert _status(self._put(app, pg_conn, agent_id, STRANGER, {"name": "x"})) == 404
|
||||
|
||||
def test_editor_policy_change_is_applied_not_dropped(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
body = {
|
||||
"config": {"guardrails": {"enabled": True}},
|
||||
"limited_request_mode": True,
|
||||
"request_limit": 7,
|
||||
}
|
||||
assert _status(self._put(app, pg_conn, agent_id, EDITOR, body)) == 200
|
||||
row = _row(pg_conn, agent_id)
|
||||
assert row["config"]["guardrails"]["enabled"] is True
|
||||
assert row["request_limit"] == 7
|
||||
|
||||
def test_editor_publish_mints_key_and_returns_it(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn, status="draft", key="")
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR, {"status": "published"})
|
||||
assert _status(resp) == 200
|
||||
assert _json(resp).get("key")
|
||||
|
||||
def test_key_withheld_when_access_details_switch_off(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn, status="draft", key="")
|
||||
set_settings(pg_conn, "agent", agent_id,
|
||||
{"editors_can_manage_access_details": False}, OWNER)
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR, {"status": "published"})
|
||||
assert _status(resp) == 200
|
||||
assert "key" not in _json(resp)
|
||||
assert _row(pg_conn, agent_id)["key"]
|
||||
|
||||
def test_editor_cannot_move_folder(self, app, pg_conn):
|
||||
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
folder = AgentFoldersRepository(pg_conn).create(OWNER, "F")
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR, {"folder_id": str(folder["id"])})
|
||||
assert _status(resp) == 403
|
||||
|
||||
def test_editor_saving_unchanged_folder_is_fine(self, app, pg_conn):
|
||||
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
|
||||
folder = AgentFoldersRepository(pg_conn).create(OWNER, "F")
|
||||
agent_id = _agent(pg_conn, folder_id=str(folder["id"]))
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR,
|
||||
{"name": "n", "folder_id": str(folder["id"])})
|
||||
assert _status(resp) == 200
|
||||
|
||||
def test_workflow_validated_against_owner(self, app, pg_conn):
|
||||
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
|
||||
agent_id = _agent(pg_conn, agent_type="workflow")
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
|
||||
assert _status(resp) == 200
|
||||
assert str(_row(pg_conn, agent_id)["workflow_id"]) == str(wf["id"])
|
||||
|
||||
mine = WorkflowsRepository(pg_conn).create(EDITOR, "editor-wf")
|
||||
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(mine["id"])})
|
||||
assert _status(resp) == 404
|
||||
|
||||
def test_tool_gate(self, app, pg_conn):
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
tools = UserToolsRepository(pg_conn)
|
||||
owner_tool = str(tools.create(OWNER, "api_tool")["id"])
|
||||
foreign_tool = str(tools.create(STRANGER, "api_tool")["id"])
|
||||
shared_tool = str(tools.create(STRANGER, "api_tool")["id"])
|
||||
_team_share(pg_conn, "tool", shared_tool, owner=STRANGER)
|
||||
agent_id = _agent(pg_conn)
|
||||
|
||||
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
|
||||
assert _status(ok) == 200
|
||||
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
|
||||
assert _status(denied) == 403
|
||||
|
||||
set_settings(pg_conn, "tool", shared_tool, {"viewers_can_use_in_agents": False}, STRANGER)
|
||||
# Already attached: keeping it is fine.
|
||||
assert _status(self._put(app, pg_conn, agent_id, EDITOR,
|
||||
{"tools": [owner_tool, shared_tool]})) == 200
|
||||
|
||||
|
||||
class TestCreateAgentToolGate:
|
||||
def test_foreign_tool_rejected(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.routes import CreateAgent
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
foreign_tool = str(UserToolsRepository(pg_conn).create(STRANGER, "api_tool")["id"])
|
||||
body = {"name": "n", "status": "draft", "tools": [foreign_tool]}
|
||||
resp = _call(app, pg_conn, CreateAgent, "post", "/api/create_agent", OWNER, json=body)
|
||||
assert _status(resp) == 403
|
||||
|
||||
def test_own_tool_accepted(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.routes import CreateAgent
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
|
||||
body = {"name": "n", "status": "draft", "tools": [tool]}
|
||||
resp = _call(app, pg_conn, CreateAgent, "post", "/api/create_agent", OWNER, json=body)
|
||||
assert _status(resp) == 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# access details: key, webhook, public link
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAccessDetails:
|
||||
def _regen(self, app, conn, agent_id, user):
|
||||
from docsgpt.api.user.agents.routes import RegenerateAgentKey
|
||||
|
||||
return _call(app, conn, RegenerateAgentKey, "post",
|
||||
f"/api/regenerate_agent_key/{agent_id}", user, args=(agent_id,))
|
||||
|
||||
def test_regenerate_key_roles(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
assert _status(self._regen(app, pg_conn, agent_id, STRANGER)) == 404
|
||||
assert _status(self._regen(app, pg_conn, agent_id, VIEWER)) == 403
|
||||
resp = self._regen(app, pg_conn, agent_id, EDITOR)
|
||||
assert _status(resp) == 200
|
||||
assert _row(pg_conn, agent_id)["key"] == _json(resp)["key"]
|
||||
set_settings(pg_conn, "agent", agent_id,
|
||||
{"editors_can_manage_access_details": False}, OWNER)
|
||||
assert _status(self._regen(app, pg_conn, agent_id, EDITOR)) == 403
|
||||
|
||||
def test_webhook_roles(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.webhooks import AgentWebhook
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
path = f"/api/agent_webhook?id={agent_id}"
|
||||
assert _status(_call(app, pg_conn, AgentWebhook, "get", path, VIEWER)) == 403
|
||||
assert _status(_call(app, pg_conn, AgentWebhook, "get", path, STRANGER)) == 404
|
||||
resp = _call(app, pg_conn, AgentWebhook, "get", path, EDITOR)
|
||||
assert _status(resp) == 200
|
||||
assert _row(pg_conn, agent_id)["incoming_webhook_token"]
|
||||
|
||||
def test_share_link_roles(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.sharing import ShareAgent
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
body = {"id": agent_id, "shared": True}
|
||||
assert _status(_call(app, pg_conn, ShareAgent, "put", "/api/share_agent",
|
||||
VIEWER, json=body)) == 403
|
||||
assert _status(_call(app, pg_conn, ShareAgent, "put", "/api/share_agent",
|
||||
STRANGER, json=body)) == 404
|
||||
resp = _call(app, pg_conn, ShareAgent, "put", "/api/share_agent", EDITOR, json=body)
|
||||
assert _status(resp) == 200
|
||||
assert _row(pg_conn, agent_id)["shared"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete / export / folders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteAgent:
|
||||
def _delete(self, app, conn, agent_id, user):
|
||||
from docsgpt.api.user.agents.routes import DeleteAgent
|
||||
|
||||
return _call(app, conn, DeleteAgent, "delete", f"/api/delete_agent?id={agent_id}", user)
|
||||
|
||||
def test_editor_403_by_default(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
assert _status(self._delete(app, pg_conn, agent_id, EDITOR)) == 403
|
||||
assert _status(self._delete(app, pg_conn, agent_id, STRANGER)) == 404
|
||||
assert _row(pg_conn, agent_id) is not None
|
||||
|
||||
def test_editor_with_switch_deletes_and_settings_go(self, app, pg_conn):
|
||||
from sqlalchemy import text
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
set_settings(pg_conn, "agent", agent_id, {"editors_can_delete": True}, OWNER)
|
||||
assert _status(self._delete(app, pg_conn, agent_id, EDITOR)) == 200
|
||||
assert _row(pg_conn, agent_id) is None
|
||||
assert settings_for(pg_conn, "agent", agent_id)["editors_can_delete"] is False
|
||||
left = pg_conn.execute(
|
||||
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"),
|
||||
{"id": agent_id},
|
||||
).scalar()
|
||||
assert left == 0
|
||||
|
||||
|
||||
class TestExportAgent:
|
||||
def test_roles(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.portability import ExportAgent
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
path = f"/api/export_agent?id={agent_id}"
|
||||
assert _status(_call(app, pg_conn, ExportAgent, "get", path, VIEWER)) == 403
|
||||
assert _status(_call(app, pg_conn, ExportAgent, "get", path, STRANGER)) == 404
|
||||
assert _status(_call(app, pg_conn, ExportAgent, "get", path, EDITOR)) == 200
|
||||
|
||||
|
||||
class TestFolderMoves:
|
||||
def test_move_agent_owner_only(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.folders import MoveAgentToFolder
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
body = {"agent_id": agent_id, "folder_id": None}
|
||||
assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
|
||||
"/api/agents/folders/move_agent", EDITOR, json=body)) == 403
|
||||
assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
|
||||
"/api/agents/folders/move_agent", STRANGER, json=body)) == 404
|
||||
assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
|
||||
"/api/agents/folders/move_agent", OWNER, json=body)) == 200
|
||||
|
||||
def test_bulk_move_reports_skipped(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.folders import BulkMoveAgents
|
||||
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
|
||||
shared_id = _agent(pg_conn)
|
||||
own = str(AgentsRepository(pg_conn).create(EDITOR, "Mine", "draft")["id"])
|
||||
folder = AgentFoldersRepository(pg_conn).create(EDITOR, "F")
|
||||
body = {"agent_ids": [own, shared_id], "folder_id": str(folder["id"])}
|
||||
resp = _call(app, pg_conn, BulkMoveAgents, "post", "/api/agents/folders/bulk_move",
|
||||
EDITOR, json=body)
|
||||
assert _status(resp) == 200
|
||||
assert _json(resp)["moved"] == [own]
|
||||
assert _json(resp)["skipped"] == [shared_id]
|
||||
assert _row(pg_conn, shared_id)["folder_id"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pinned / link-shared lists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListsCarryAccess:
|
||||
def test_pinned_agents(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.routes import PinAgent, PinnedAgents
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
assert _status(_call(app, pg_conn, PinAgent, "post",
|
||||
f"/api/pin_agent?id={agent_id}", VIEWER)) == 200
|
||||
rows = _json(_call(app, pg_conn, PinnedAgents, "get", "/api/pinned_agents", VIEWER))
|
||||
assert rows[0]["access"] == "viewer"
|
||||
assert rows[0]["allowed_actions"] == ["pin", "use"]
|
||||
|
||||
def test_link_shared_agent_is_viewer(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.sharing import SharedAgent, SharedAgents
|
||||
|
||||
agent_id = _agent(pg_conn, share=False)
|
||||
AgentsRepository(pg_conn).update(agent_id, OWNER, {"shared": True, "shared_token": "tok-r"})
|
||||
resp = _call(app, pg_conn, SharedAgent, "get", "/api/shared_agent?token=tok-r", STRANGER)
|
||||
data = _json(resp)
|
||||
assert data["access"] == "viewer"
|
||||
assert data["allowed_actions"] == ["pin", "use"]
|
||||
assert "user" not in data
|
||||
rows = _json(_call(app, pg_conn, SharedAgents, "get", "/api/shared_agents", STRANGER))
|
||||
assert rows[0]["access"] == "viewer"
|
||||
assert rows[0]["allowed_actions"] == ["pin", "use"]
|
||||
|
||||
def test_link_shared_agent_with_team_grant_uses_grant(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.sharing import SharedAgent
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
AgentsRepository(pg_conn).update(agent_id, OWNER, {"shared": True, "shared_token": "tok-e"})
|
||||
data = _json(_call(app, pg_conn, SharedAgent, "get", "/api/shared_agent?token=tok-e", EDITOR))
|
||||
assert data["access"] == "editor"
|
||||
assert "edit" in data["allowed_actions"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# schedules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_RUN_NOW_TASK = type("T", (), {"apply_async": staticmethod(lambda **k: None)})
|
||||
|
||||
|
||||
class TestSchedules:
|
||||
def _create(self, app, conn, agent_id, user):
|
||||
from docsgpt.api.user.schedules.routes import AgentSchedules
|
||||
|
||||
body = {"instruction": "do it", "cron": "0 9 * * *"}
|
||||
return _call(app, conn, AgentSchedules, "post", f"/api/agents/{agent_id}/schedules",
|
||||
user, json=body, args=(agent_id,))
|
||||
|
||||
def test_editor_creates_as_owner(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
resp = self._create(app, pg_conn, agent_id, EDITOR)
|
||||
assert _status(resp) == 201
|
||||
assert _json(resp)["schedule"]["user_id"] == OWNER
|
||||
|
||||
def test_viewer_403_stranger_404(self, app, pg_conn):
|
||||
from docsgpt.api.user.schedules.routes import AgentSchedules, AgentScheduleStats
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
assert _status(self._create(app, pg_conn, agent_id, VIEWER)) == 403
|
||||
assert _status(self._create(app, pg_conn, agent_id, STRANGER)) == 404
|
||||
path = f"/api/agents/{agent_id}/schedules"
|
||||
assert _status(_call(app, pg_conn, AgentSchedules, "get", path, VIEWER,
|
||||
args=(agent_id,))) == 403
|
||||
assert _status(_call(app, pg_conn, AgentScheduleStats, "get", path + "/stats",
|
||||
VIEWER, args=(agent_id,))) == 403
|
||||
|
||||
def test_editor_manages_owner_schedule(self, app, pg_conn):
|
||||
from docsgpt.api.user.schedules.routes import (
|
||||
AgentSchedules,
|
||||
ScheduleResource,
|
||||
ScheduleRunList,
|
||||
ScheduleRunNow,
|
||||
)
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
sid = _json(self._create(app, pg_conn, agent_id, OWNER))["schedule"]["id"]
|
||||
|
||||
listed = _call(app, pg_conn, AgentSchedules, "get", f"/api/agents/{agent_id}/schedules",
|
||||
EDITOR, args=(agent_id,))
|
||||
assert [s["id"] for s in _json(listed)["schedules"]] == [sid]
|
||||
|
||||
path = f"/api/schedules/{sid}"
|
||||
assert _status(_call(app, pg_conn, ScheduleResource, "get", path, EDITOR,
|
||||
args=(sid,))) == 200
|
||||
assert _status(_call(app, pg_conn, ScheduleResource, "get", path, VIEWER,
|
||||
args=(sid,))) == 403
|
||||
assert _status(_call(app, pg_conn, ScheduleResource, "get", path, STRANGER,
|
||||
args=(sid,))) == 404
|
||||
put = _call(app, pg_conn, ScheduleResource, "put", path, EDITOR,
|
||||
json={"name": "renamed"}, args=(sid,))
|
||||
assert _json(put)["schedule"]["name"] == "renamed"
|
||||
patched = _call(app, pg_conn, ScheduleResource, "patch", path, EDITOR,
|
||||
json={"action": "pause"}, args=(sid,))
|
||||
assert _json(patched)["schedule"]["status"] == "paused"
|
||||
|
||||
with patch("docsgpt.api.user.tasks.execute_scheduled_run", _RUN_NOW_TASK):
|
||||
run = _call(app, pg_conn, ScheduleRunNow, "post", path + "/run", EDITOR, args=(sid,))
|
||||
assert _status(run) == 202
|
||||
assert _json(run)["run"]["user_id"] == OWNER
|
||||
runs = _call(app, pg_conn, ScheduleRunList, "get", path + "/runs", EDITOR, args=(sid,))
|
||||
assert len(_json(runs)["runs"]) == 1
|
||||
|
||||
assert _status(_call(app, pg_conn, ScheduleResource, "delete", path, VIEWER,
|
||||
args=(sid,))) == 403
|
||||
assert _status(_call(app, pg_conn, ScheduleResource, "delete", path, EDITOR,
|
||||
args=(sid,))) == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# workflows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wf_body(name="WF"):
|
||||
return {
|
||||
"name": name,
|
||||
"description": "d",
|
||||
"nodes": [
|
||||
{"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
|
||||
{"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
|
||||
],
|
||||
"edges": [{"id": "e1", "source": "start1", "target": "end1"}],
|
||||
}
|
||||
|
||||
|
||||
class TestWorkflows:
|
||||
def _setup(self, pg_conn):
|
||||
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
|
||||
_agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
|
||||
return str(wf["id"])
|
||||
|
||||
def test_roles(self, app, pg_conn):
|
||||
from docsgpt.api.user.workflows.routes import WorkflowDetail
|
||||
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
wid = self._setup(pg_conn)
|
||||
path = f"/api/workflows/{wid}"
|
||||
assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, EDITOR, args=(wid,))) == 200
|
||||
assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, VIEWER, args=(wid,))) == 403
|
||||
assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, STRANGER, args=(wid,))) == 404
|
||||
|
||||
put = _call(app, pg_conn, WorkflowDetail, "put", path, EDITOR,
|
||||
json=_wf_body("Edited"), args=(wid,))
|
||||
assert _status(put) == 200
|
||||
row = WorkflowsRepository(pg_conn).get_by_id(wid)
|
||||
assert row["name"] == "Edited"
|
||||
assert row["user_id"] == OWNER
|
||||
assert _status(_call(app, pg_conn, WorkflowDetail, "put", path, VIEWER,
|
||||
json=_wf_body(), args=(wid,))) == 403
|
||||
|
||||
assert _status(_call(app, pg_conn, WorkflowDetail, "delete", path, EDITOR,
|
||||
args=(wid,))) == 403
|
||||
assert WorkflowsRepository(pg_conn).get_by_id(wid) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# analytics / logs / guardrail events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_conversation(conn, user_id, agent_id, count=2):
|
||||
from docsgpt.storage.db.repositories.conversations import ConversationsRepository
|
||||
|
||||
repo = ConversationsRepository(conn)
|
||||
conv = repo.create(user_id, name="t", agent_id=agent_id)
|
||||
for i in range(count):
|
||||
repo.append_message(str(conv["id"]), {"prompt": f"p{i}", "response": f"r{i}"})
|
||||
|
||||
|
||||
class TestAnalytics:
|
||||
def _messages(self, app, conn, agent_id, user):
|
||||
from docsgpt.api.user.analytics.routes import GetMessageAnalytics
|
||||
|
||||
return _call(app, conn, GetMessageAnalytics, "post", "/api/get_message_analytics",
|
||||
user, json={"api_key_id": agent_id})
|
||||
|
||||
def test_editor_sees_owner_view(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
_seed_conversation(pg_conn, OWNER, agent_id, count=2)
|
||||
_seed_conversation(pg_conn, "someone-else", agent_id, count=1)
|
||||
resp = self._messages(app, pg_conn, agent_id, EDITOR)
|
||||
assert _status(resp) == 200
|
||||
assert sum(_json(resp)["messages"].values()) == 3
|
||||
|
||||
def test_viewer_403_unless_switch(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
_seed_conversation(pg_conn, OWNER, agent_id, count=2)
|
||||
assert _status(self._messages(app, pg_conn, agent_id, VIEWER)) == 403
|
||||
set_settings(pg_conn, "agent", agent_id, {"viewers_can_see_logs": True}, OWNER)
|
||||
resp = self._messages(app, pg_conn, agent_id, VIEWER)
|
||||
assert _status(resp) == 200
|
||||
assert sum(_json(resp)["messages"].values()) == 2
|
||||
|
||||
def test_stranger_gets_empty(self, app, pg_conn):
|
||||
agent_id = _agent(pg_conn)
|
||||
_seed_conversation(pg_conn, OWNER, agent_id, count=2)
|
||||
resp = self._messages(app, pg_conn, agent_id, STRANGER)
|
||||
assert _status(resp) == 200
|
||||
assert sum(_json(resp)["messages"].values()) == 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cls_name,path",
|
||||
[
|
||||
("GetTokenAnalytics", "/api/get_token_analytics"),
|
||||
("GetFeedbackAnalytics", "/api/get_feedback_analytics"),
|
||||
("GetToolAnalytics", "/api/get_tool_analytics"),
|
||||
("GetScheduleAnalytics", "/api/get_schedule_analytics"),
|
||||
("GetUserLogs", "/api/get_user_logs"),
|
||||
],
|
||||
)
|
||||
def test_other_endpoints_gate_viewer(self, app, pg_conn, cls_name, path):
|
||||
from docsgpt.api.user.analytics import routes
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
cls = getattr(routes, cls_name)
|
||||
body = {"api_key_id": agent_id}
|
||||
assert _status(_call(app, pg_conn, cls, "post", path, VIEWER, json=body)) == 403
|
||||
assert _status(_call(app, pg_conn, cls, "post", path, EDITOR, json=body)) == 200
|
||||
|
||||
def test_logs_editor_sees_owner_chats(self, app, pg_conn):
|
||||
from docsgpt.api.user.analytics.routes import GetUserLogs
|
||||
from docsgpt.storage.db.repositories.user_logs import UserLogsRepository
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
UserLogsRepository(pg_conn).insert(
|
||||
user_id=OWNER, endpoint="stream",
|
||||
data={"agent_id": agent_id, "question": "q", "level": "info"},
|
||||
)
|
||||
resp = _call(app, pg_conn, GetUserLogs, "post", "/api/get_user_logs", EDITOR,
|
||||
json={"api_key_id": agent_id, "event_type": "chat"})
|
||||
assert _status(resp) == 200
|
||||
assert len(_json(resp)["logs"]) == 1
|
||||
|
||||
def test_traces_gate(self, app, pg_conn):
|
||||
from docsgpt.api.user.analytics.routes import GetTraces
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
path = f"/api/traces?request_id=r1&api_key_id={agent_id}"
|
||||
assert _status(_call(app, pg_conn, GetTraces, "get", path, VIEWER)) == 403
|
||||
assert _status(_call(app, pg_conn, GetTraces, "get", path, EDITOR)) == 200
|
||||
|
||||
|
||||
class TestGuardrailEvents:
|
||||
def _seed(self, conn, agent_id):
|
||||
from docsgpt.storage.db.repositories.guardrail_events import GuardrailEventsRepository
|
||||
|
||||
GuardrailEventsRepository(conn).record_many([
|
||||
{
|
||||
"user_id": OWNER, "agent_id": agent_id, "stage": "input",
|
||||
"check_name": "pii", "detector_type": "regex", "action": "block",
|
||||
"outcome": "triggered",
|
||||
}
|
||||
])
|
||||
|
||||
def test_events_and_summary(self, app, pg_conn):
|
||||
from docsgpt.api.user.agents.guardrails import GuardrailEvents, GuardrailSummary
|
||||
|
||||
agent_id = _agent(pg_conn)
|
||||
self._seed(pg_conn, agent_id)
|
||||
path = f"/api/guardrails/events?agent_id={agent_id}"
|
||||
resp = _call(app, pg_conn, GuardrailEvents, "get", path, EDITOR)
|
||||
assert _status(resp) == 200
|
||||
assert len(_json(resp)["events"]) == 1
|
||||
assert _status(_call(app, pg_conn, GuardrailEvents, "get", path, VIEWER)) == 403
|
||||
assert _status(_call(app, pg_conn, GuardrailEvents, "get", path, STRANGER)) == 404
|
||||
|
||||
spath = f"/api/guardrails/summary?agent_id={agent_id}"
|
||||
summary = _call(app, pg_conn, GuardrailSummary, "get", spath, EDITOR)
|
||||
assert _status(summary) == 200
|
||||
assert _json(summary)["totals"]["blocked"] == 1
|
||||
assert _status(_call(app, pg_conn, GuardrailSummary, "get", spath, VIEWER)) == 403
|
||||
@@ -30,15 +30,12 @@ def _seed_source(pg_conn, user="u", name="src"):
|
||||
|
||||
|
||||
class TestResolveSource:
|
||||
def test_returns_none_for_missing(self, pg_conn):
|
||||
def test_missing_raises_404(self, pg_conn):
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.chunks import _resolve_source
|
||||
with _patch_db(pg_conn):
|
||||
assert (
|
||||
_resolve_source(
|
||||
"00000000-0000-0000-0000-000000000000", "u"
|
||||
)
|
||||
is None
|
||||
)
|
||||
with _patch_db(pg_conn), pytest.raises(AccessDenied) as exc:
|
||||
_resolve_source("00000000-0000-0000-0000-000000000000", "u")
|
||||
assert exc.value.status == 404
|
||||
|
||||
def test_returns_source_when_found(self, pg_conn):
|
||||
from docsgpt.api.user.sources.chunks import _resolve_source
|
||||
@@ -49,7 +46,8 @@ class TestResolveSource:
|
||||
assert got is not None
|
||||
assert str(got["id"]) == str(src["id"])
|
||||
|
||||
def test_team_viewer_can_read(self, pg_conn):
|
||||
def test_team_viewer_can_read_not_edit(self, pg_conn):
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.chunks import _resolve_source
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import TeamResourceGrantsRepository
|
||||
@@ -65,10 +63,13 @@ class TestResolveSource:
|
||||
)
|
||||
with _patch_db(pg_conn):
|
||||
got = _resolve_source(str(src["id"]), viewer)
|
||||
stranger = _resolve_source(str(src["id"]), "u-resolve-stranger")
|
||||
assert got is not None
|
||||
with pytest.raises(AccessDenied) as edit:
|
||||
_resolve_source(str(src["id"]), viewer, "edit")
|
||||
with pytest.raises(AccessDenied) as stranger:
|
||||
_resolve_source(str(src["id"]), "u-resolve-stranger")
|
||||
assert str(got["id"]) == str(src["id"])
|
||||
assert stranger is None
|
||||
assert edit.value.status == 403
|
||||
assert stranger.value.status == 404
|
||||
|
||||
|
||||
class TestChunkMatchesPath:
|
||||
@@ -398,9 +399,9 @@ class TestAddChunk:
|
||||
response = AddChunk().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_403_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team editor grant resolves to None, which the
|
||||
# owner-or-editor gate answers as 403 "Source not accessible".
|
||||
def test_returns_404_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team grant: the source isn't visible → 404
|
||||
# (403 is reserved for a visible source the role can't change).
|
||||
from docsgpt.api.user.sources.chunks import AddChunk
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
@@ -413,7 +414,7 @@ class TestAddChunk:
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = AddChunk().post()
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_adds_chunk(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.chunks import AddChunk
|
||||
@@ -472,9 +473,9 @@ class TestDeleteChunk:
|
||||
response = DeleteChunk().delete()
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_403_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team editor grant resolves to None, which the
|
||||
# owner-or-editor gate answers as 403 "Source not accessible".
|
||||
def test_returns_404_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team grant: the source isn't visible → 404
|
||||
# (403 is reserved for a visible source the role can't change).
|
||||
from docsgpt.api.user.sources.chunks import DeleteChunk
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
@@ -484,7 +485,7 @@ class TestDeleteChunk:
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = DeleteChunk().delete()
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_deletes_chunk(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.chunks import DeleteChunk
|
||||
@@ -551,9 +552,9 @@ class TestUpdateChunk:
|
||||
response = UpdateChunk().put()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_403_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team editor grant resolves to None, which the
|
||||
# owner-or-editor gate answers as 403 "Source not accessible".
|
||||
def test_returns_404_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team grant: the source isn't visible → 404
|
||||
# (403 is reserved for a visible source the role can't change).
|
||||
from docsgpt.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
@@ -566,7 +567,7 @@ class TestUpdateChunk:
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = UpdateChunk().put()
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_returns_404_chunk_not_found(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
@@ -53,6 +53,8 @@ def _run(sub, repo, team_shared, client):
|
||||
"docsgpt.api.user.sources.routes.visible_with_access",
|
||||
return_value=team_shared,
|
||||
),
|
||||
# Owner switches all at their defaults.
|
||||
patch("docsgpt.api.user.sources.routes.settings_many", return_value={}),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
@@ -80,6 +82,9 @@ class TestPaginatedSourcesTeamSharing:
|
||||
assert by_id[owned]["team_access"] is None
|
||||
assert by_id[shared]["ownership"] == "team"
|
||||
assert by_id[shared]["team_access"] == "editor"
|
||||
assert by_id[owned]["access"] == "owner"
|
||||
assert by_id[shared]["access"] == "editor"
|
||||
assert "edit" in by_id[shared]["allowed_actions"]
|
||||
# The shared ids are unioned into the owner-scoped queries by id.
|
||||
assert repo.list_for_user.call_args.kwargs["extra_ids"] == [shared]
|
||||
assert repo.count_for_user.call_args.kwargs["extra_ids"] == [shared]
|
||||
|
||||
@@ -526,9 +526,9 @@ class TestSyncSource:
|
||||
response = SyncSource().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_403_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team editor grant resolves to None, which the
|
||||
# owner-or-editor gate answers as 403 "Source not accessible".
|
||||
def test_returns_404_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team grant: the source isn't visible → 404
|
||||
# (403 is reserved for a visible source the role can't change).
|
||||
from docsgpt.api.user.sources.routes import SyncSource
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
@@ -539,7 +539,7 @@ class TestSyncSource:
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = SyncSource().post()
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_returns_400_for_connector_type(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.routes import SyncSource
|
||||
@@ -675,9 +675,9 @@ class TestReingestSource:
|
||||
response = ReingestSource().post()
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_returns_403_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team editor grant resolves to None, which the
|
||||
# owner-or-editor gate answers as 403 "Source not accessible".
|
||||
def test_returns_404_inaccessible_source(self, app, pg_conn):
|
||||
# No ownership and no team grant: the source isn't visible → 404
|
||||
# (403 is reserved for a visible source the role can't change).
|
||||
from docsgpt.api.user.sources.routes import ReingestSource
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
@@ -688,7 +688,7 @@ class TestReingestSource:
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u"}
|
||||
response = ReingestSource().post()
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_triggers_reingest_task(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.routes import ReingestSource
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Owner / editor / viewer / stranger matrix for the source endpoints.
|
||||
|
||||
Every source route resolves the caller's role through
|
||||
``docsgpt.api.user.resource_access``: 404 when the source isn't visible, 403
|
||||
when it is but the role can't perform the action, and writes land as the owner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
OWNER = "alice-roles"
|
||||
EDITOR = "bob-roles-editor"
|
||||
VIEWER = "carol-roles-viewer"
|
||||
STRANGER = "dave-roles-stranger"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn, *modules):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
patches = []
|
||||
for mod in modules:
|
||||
for name in ("db_session", "db_readonly"):
|
||||
target = f"{mod}.{name}"
|
||||
try:
|
||||
p = patch(target, _yield)
|
||||
p.start()
|
||||
patches.append(p)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
|
||||
|
||||
ROUTES = "docsgpt.api.user.sources.routes"
|
||||
CHUNKS = "docsgpt.api.user.sources.chunks"
|
||||
UPLOAD = "docsgpt.api.user.sources.upload"
|
||||
CONNECTOR = "docsgpt.api.connector.routes"
|
||||
|
||||
|
||||
def _shared_source(pg_conn, **kwargs):
|
||||
"""Seed a source owned by OWNER, shared to EDITOR (editor) and VIEWER (viewer)."""
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
kwargs.pop("name", "shared-src"), user_id=OWNER, **kwargs
|
||||
)
|
||||
sid = str(src["id"])
|
||||
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
|
||||
team = TeamsRepository(pg_conn).create(
|
||||
f"T-{level}", f"t-{level}-{uuid.uuid4().hex[:8]}", OWNER
|
||||
)
|
||||
TeamMembersRepository(pg_conn).add_member(team["id"], member, role="team_member")
|
||||
TeamResourceGrantsRepository(pg_conn).grant(
|
||||
team["id"], "source", sid, owner_id=OWNER, granted_by=OWNER,
|
||||
access_level=level,
|
||||
)
|
||||
return sid
|
||||
|
||||
|
||||
def _set(pg_conn, sid, **switches):
|
||||
from docsgpt.api.user.resource_access import set_settings
|
||||
|
||||
set_settings(pg_conn, "source", sid, switches, OWNER)
|
||||
|
||||
|
||||
def _call(app, user, path, fn, method="GET", **ctx):
|
||||
with app.test_request_context(path, method=method, **ctx):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
return fn()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListPayload:
|
||||
def test_sources_rows_carry_access(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.routes import CombinedJson
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
owner = _call(app, OWNER, "/api/sources", CombinedJson().get)
|
||||
editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
|
||||
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
|
||||
o = next(r for r in owner.json if r["id"] == sid)
|
||||
e = next(r for r in editor.json if r["id"] == sid)
|
||||
v = next(r for r in viewer.json if r["id"] == sid)
|
||||
assert o["access"] == "owner"
|
||||
assert "delete" in o["allowed_actions"]
|
||||
assert o["ownership"] == "user"
|
||||
assert e["access"] == "editor"
|
||||
assert "edit" in e["allowed_actions"] and "delete" not in e["allowed_actions"]
|
||||
assert e["allowed_actions"] == sorted(e["allowed_actions"])
|
||||
assert e["team_access"] == "editor"
|
||||
assert v["access"] == "viewer"
|
||||
assert v["allowed_actions"] == ["use", "view_config"]
|
||||
# viewers_can_see_config defaults on.
|
||||
assert "config" in v
|
||||
|
||||
def test_viewer_config_hidden_when_switch_off(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.routes import CombinedJson, PaginatedSources
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
_set(pg_conn, sid, viewers_can_see_config=False)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
|
||||
editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
|
||||
vpage = _call(app, VIEWER, "/api/sources/paginated", PaginatedSources().get)
|
||||
v = next(r for r in viewer.json if r["id"] == sid)
|
||||
e = next(r for r in editor.json if r["id"] == sid)
|
||||
vp = next(r for r in vpage.json["paginated"] if r["id"] == sid)
|
||||
assert "config" not in v
|
||||
assert v["allowed_actions"] == ["use"]
|
||||
assert "config" not in vp
|
||||
assert "config" in e
|
||||
|
||||
def test_paginated_rows_carry_access(self, app, pg_conn):
|
||||
from docsgpt.api.user.sources.routes import PaginatedSources
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
_set(pg_conn, sid, editors_can_delete=True)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
owner = _call(app, OWNER, "/api/sources/paginated", PaginatedSources().get)
|
||||
editor = _call(app, EDITOR, "/api/sources/paginated", PaginatedSources().get)
|
||||
o = next(r for r in owner.json["paginated"] if r["id"] == sid)
|
||||
e = next(r for r in editor.json["paginated"] if r["id"] == sid)
|
||||
assert o["access"] == "owner"
|
||||
assert e["access"] == "editor"
|
||||
assert "delete" in e["allowed_actions"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _delete(app, pg_conn, user, sid):
|
||||
from docsgpt.api.user.sources.routes import DeleteOldIndexes
|
||||
|
||||
storage = MagicMock()
|
||||
storage.is_directory.return_value = False
|
||||
with _patch_db(pg_conn, ROUTES), patch(
|
||||
f"{ROUTES}.settings.VECTOR_STORE", "milvus"
|
||||
), patch(f"{ROUTES}.StorageCreator.get_storage", return_value=storage), patch(
|
||||
f"{ROUTES}.VectorCreator.create_vectorstore", return_value=MagicMock()
|
||||
):
|
||||
return _call(app, user, f"/api/delete_old?source_id={sid}", DeleteOldIndexes().get)
|
||||
|
||||
|
||||
class TestDelete:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 403), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_non_owner_denied_by_default(self, app, pg_conn, user, status):
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
assert _delete(app, pg_conn, user, sid).status_code == status
|
||||
assert SourcesRepository(pg_conn).get_by_id(sid) is not None
|
||||
|
||||
def test_editor_deletes_when_switch_on(self, app, pg_conn):
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
_set(pg_conn, sid, editors_can_delete=True)
|
||||
assert _delete(app, pg_conn, EDITOR, sid).status_code == 200
|
||||
assert SourcesRepository(pg_conn).get_by_id(sid) is None
|
||||
grants = pg_conn.execute(
|
||||
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:i AS uuid)"),
|
||||
{"i": sid},
|
||||
).scalar()
|
||||
settings_rows = pg_conn.execute(
|
||||
text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:i AS uuid)"),
|
||||
{"i": sid},
|
||||
).scalar()
|
||||
assert grants == 0
|
||||
assert settings_rows == 0
|
||||
|
||||
def test_viewer_still_denied_when_switch_on(self, app, pg_conn):
|
||||
sid = _shared_source(pg_conn)
|
||||
_set(pg_conn, sid, editors_can_delete=True)
|
||||
assert _delete(app, pg_conn, VIEWER, sid).status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync frequency / sync / reingest / config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestManageSync:
|
||||
@pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_matrix(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import ManageSync
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
resp = _call(
|
||||
app, user, "/api/manage_sync", ManageSync().post, method="POST",
|
||||
json={"source_id": sid, "sync_frequency": "weekly"},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
got = SourcesRepository(pg_conn).get_by_id(sid)["sync_frequency"]
|
||||
assert (got == "weekly") == (status == 200)
|
||||
|
||||
|
||||
class TestSyncSource:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_matrix_runs_as_owner(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import SyncSource
|
||||
|
||||
sid = _shared_source(
|
||||
pg_conn, type="url", remote_data={"url": "https://example.com"}
|
||||
)
|
||||
delay = MagicMock(return_value=MagicMock(id="t-1"))
|
||||
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.sync_source.delay", delay):
|
||||
resp = _call(
|
||||
app, user, "/api/sync_source", SyncSource().post, method="POST",
|
||||
json={"source_id": sid},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
if status == 200:
|
||||
assert delay.call_args.kwargs["user"] == OWNER
|
||||
else:
|
||||
delay.assert_not_called()
|
||||
|
||||
|
||||
class TestReingest:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_matrix(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import ReingestSource
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
delay = MagicMock(return_value=MagicMock(id="t-2"))
|
||||
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reingest_source_task.delay", delay):
|
||||
resp = _call(
|
||||
app, user, "/api/sources/reingest", ReingestSource().post,
|
||||
method="POST", json={"source_id": sid},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
if status == 200:
|
||||
assert delay.call_args.kwargs["user"] == OWNER
|
||||
|
||||
|
||||
class TestConfig:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_matrix(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import SourceConfigResource
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
resp = _call(
|
||||
app, user, f"/api/sources/{sid}/config",
|
||||
lambda: SourceConfigResource().patch(sid), method="PATCH",
|
||||
json={"retrieval": {"chunks": 7}},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReads:
|
||||
@pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 200), (STRANGER, 404)])
|
||||
def test_directory_structure(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import DirectoryStructure
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
resp = _call(app, user, f"/api/directory_structure?id={sid}", DirectoryStructure().get)
|
||||
assert resp.status_code == status
|
||||
|
||||
@pytest.mark.parametrize("user, status", [(VIEWER, 200), (STRANGER, 404)])
|
||||
def test_get_chunks(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.chunks import GetChunks
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
store = MagicMock()
|
||||
store.get_chunks.return_value = []
|
||||
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
|
||||
resp = _call(app, user, f"/api/get_chunks?id={sid}", GetChunks().get)
|
||||
assert resp.status_code == status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiki writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWikiWrites:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_put_page(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import WikiPage
|
||||
|
||||
sid = _shared_source(pg_conn, type="wiki", config={"kind": "wiki"})
|
||||
delay = MagicMock()
|
||||
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reembed_wiki_page.delay", delay):
|
||||
resp = _call(
|
||||
app, user, f"/api/sources/{sid}/wiki/page",
|
||||
lambda: WikiPage().put(sid), method="PUT",
|
||||
json={"path": "/a.md", "content": "hi"},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
if status == 200:
|
||||
assert delay.call_args.kwargs["user"] == OWNER
|
||||
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_convert(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.routes import ConvertSourceToWiki
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
with _patch_db(pg_conn, ROUTES):
|
||||
resp = _call(
|
||||
app, user, f"/api/sources/{sid}/wiki/convert",
|
||||
lambda: ConvertSourceToWiki().post(sid), method="POST",
|
||||
)
|
||||
assert resp.status_code == status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChunkWrites:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 201), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_add(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.chunks import AddChunk
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
store = MagicMock()
|
||||
store.add_chunk.return_value = "c1"
|
||||
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
|
||||
resp = _call(
|
||||
app, user, "/api/add_chunk", AddChunk().post, method="POST",
|
||||
json={"id": sid, "text": "hello"},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
assert store.add_chunk.called == (status == 201)
|
||||
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_delete(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.chunks import DeleteChunk
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
store = MagicMock()
|
||||
store.delete_chunk.return_value = True
|
||||
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
|
||||
resp = _call(
|
||||
app, user, f"/api/delete_chunk?id={sid}&chunk_id=c1",
|
||||
DeleteChunk().delete, method="DELETE",
|
||||
)
|
||||
assert resp.status_code == status
|
||||
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_update(self, app, pg_conn, user, status):
|
||||
from docsgpt.api.user.sources.chunks import UpdateChunk
|
||||
|
||||
sid = _shared_source(pg_conn)
|
||||
store = MagicMock()
|
||||
store.get_chunks.return_value = [{"doc_id": "c1", "text": "a", "metadata": {}}]
|
||||
store.update_chunk.return_value = "c1"
|
||||
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
|
||||
resp = _call(
|
||||
app, user, "/api/update_chunk", UpdateChunk().put, method="PUT",
|
||||
json={"id": sid, "chunk_id": "c1", "text": "b"},
|
||||
)
|
||||
assert resp.status_code == status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestManageSourceFiles:
|
||||
@pytest.mark.parametrize("user, status", [(EDITOR, 400), (VIEWER, 403), (STRANGER, 404)])
|
||||
def test_matrix(self, app, pg_conn, user, status):
|
||||
# An editor passes the role check and reaches the operation's own
|
||||
# validation (no files → 400); viewer and stranger stop at the check.
|
||||
from docsgpt.api.user.sources.upload import ManageSourceFiles
|
||||
|
||||
sid = _shared_source(pg_conn, file_path="/data/src")
|
||||
with _patch_db(pg_conn, UPLOAD), patch(
|
||||
f"{UPLOAD}.StorageCreator.get_storage", return_value=MagicMock()
|
||||
):
|
||||
resp = _call(
|
||||
app, user, "/api/manage_source_files", ManageSourceFiles().post,
|
||||
method="POST", data={"source_id": sid, "operation": "add"},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connector sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _connector_source(pg_conn):
|
||||
return _shared_source(
|
||||
pg_conn, name="drive-src", type="connector:file",
|
||||
remote_data={"provider": "google_drive", "file_ids": ["f"], "folder_ids": []},
|
||||
)
|
||||
|
||||
|
||||
def _owner_session(pg_conn, token="st-owner", token_info=None):
|
||||
from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
)
|
||||
|
||||
repo = ConnectorSessionsRepository(pg_conn)
|
||||
row = repo.upsert(OWNER, "google_drive", status="authorized")
|
||||
repo.update(
|
||||
str(row["id"]),
|
||||
{
|
||||
"session_token": token,
|
||||
"token_info": token_info or {"access_token": "a", "refresh_token": "r"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _connector_sync(app, pg_conn, user, body):
|
||||
from docsgpt.api.connector.routes import ConnectorSync
|
||||
|
||||
delay = MagicMock(return_value=MagicMock(id="t-sync"))
|
||||
with _patch_db(pg_conn, CONNECTOR), patch(
|
||||
f"{CONNECTOR}.ingest_connector_task.delay", delay
|
||||
):
|
||||
resp = _call(app, user, "/api/connectors/sync", ConnectorSync().post, method="POST", json=body)
|
||||
return resp, delay
|
||||
|
||||
|
||||
class TestConnectorSync:
|
||||
def test_editor_syncs_with_owner_session(self, app, pg_conn):
|
||||
sid = _connector_source(pg_conn)
|
||||
_owner_session(pg_conn)
|
||||
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
|
||||
assert resp.status_code == 200
|
||||
kwargs = delay.call_args.kwargs
|
||||
assert kwargs["user"] == OWNER
|
||||
assert kwargs["session_token"] == "st-owner"
|
||||
|
||||
def test_editor_cannot_substitute_own_session(self, app, pg_conn):
|
||||
from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
)
|
||||
|
||||
sid = _connector_source(pg_conn)
|
||||
_owner_session(pg_conn)
|
||||
repo = ConnectorSessionsRepository(pg_conn)
|
||||
row = repo.upsert(EDITOR, "google_drive", status="authorized")
|
||||
repo.update(str(row["id"]), {"session_token": "st-editor", "token_info": {"access_token": "x"}})
|
||||
resp, delay = _connector_sync(
|
||||
app, pg_conn, EDITOR, {"source_id": sid, "session_token": "st-editor"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert delay.call_args.kwargs["session_token"] == "st-owner"
|
||||
|
||||
def test_owner_session_missing_returns_409(self, app, pg_conn):
|
||||
sid = _connector_source(pg_conn)
|
||||
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
|
||||
assert resp.status_code == 409
|
||||
assert "owner" in resp.json["error"].lower()
|
||||
delay.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("user, status", [(VIEWER, 403), (STRANGER, 404)])
|
||||
def test_viewer_and_stranger_denied(self, app, pg_conn, user, status):
|
||||
sid = _connector_source(pg_conn)
|
||||
_owner_session(pg_conn)
|
||||
resp, delay = _connector_sync(app, pg_conn, user, {"source_id": sid})
|
||||
assert resp.status_code == status
|
||||
delay.assert_not_called()
|
||||
|
||||
def test_owner_still_uses_own_token(self, app, pg_conn):
|
||||
sid = _connector_source(pg_conn)
|
||||
_owner_session(pg_conn)
|
||||
resp, delay = _connector_sync(
|
||||
app, pg_conn, OWNER, {"source_id": sid, "session_token": "st-owner"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert delay.call_args.kwargs["user"] == OWNER
|
||||
Whitespace-only changes.
@@ -0,0 +1,531 @@
|
||||
"""Team sharing routes on the role model (``resource_access``), against real Postgres.
|
||||
|
||||
Driven through the real app.py chokepoint; only ``handle_auth`` /
|
||||
``resolve_roles`` are patched, and the route modules' sessions are pointed at
|
||||
the per-test ``pg_conn``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.api.user.resource_access import set_settings, settings_for
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from docsgpt.app import app as flask_app
|
||||
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(pg_conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield pg_conn
|
||||
|
||||
with ExitStack() as stack:
|
||||
for target in (
|
||||
"docsgpt.api.user.teams.routes.db_readonly",
|
||||
"docsgpt.api.user.teams.routes.db_session",
|
||||
"docsgpt.api.user.team_authz.db_readonly",
|
||||
):
|
||||
stack.enter_context(patch(target, _yield))
|
||||
stack.enter_context(patch("docsgpt.api.user.teams.routes.publish_user_event"))
|
||||
yield pg_conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _as(sub, *roles):
|
||||
with patch("docsgpt.app.handle_auth", return_value={"sub": sub}), patch(
|
||||
"docsgpt.app.resolve_roles", return_value=list(roles) or ["user"]
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.data)
|
||||
|
||||
|
||||
def _team(conn, owner="alice", members=(("bob", "team_member"),)):
|
||||
team = TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
|
||||
tid = str(team["id"])
|
||||
repo = TeamMembersRepository(conn)
|
||||
repo.add_member(tid, owner, role="team_admin")
|
||||
for user, role in members:
|
||||
repo.add_member(tid, user, role=role)
|
||||
return tid
|
||||
|
||||
|
||||
def _agent(conn, owner="alice", name="Helper"):
|
||||
return str(AgentsRepository(conn).create(owner, name, "published")["id"])
|
||||
|
||||
|
||||
def _grant(conn, tid, rid, level="viewer", rtype="agent", owner="alice", target=None):
|
||||
TeamResourceGrantsRepository(conn).grant(
|
||||
tid, rtype, rid, owner, owner, access_level=level, target_user_id=target
|
||||
)
|
||||
|
||||
|
||||
def _grants(conn, tid):
|
||||
return TeamResourceGrantsRepository(conn).list_for_team(tid)
|
||||
|
||||
|
||||
# --- POST /api/teams/<tid>/grants -------------------------------------------
|
||||
|
||||
|
||||
class TestShareNeedsShareAction:
|
||||
def test_editor_without_switch_gets_403(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor")
|
||||
with _as("bob"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants",
|
||||
json={"resource_type": "agent", "resource_id": aid, "target_user_id": "carol"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_editor_with_switch_can_share_as_owner(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor")
|
||||
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
with _as("bob"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants",
|
||||
json={
|
||||
"resource_type": "agent",
|
||||
"resource_id": aid,
|
||||
"target_user_id": "carol",
|
||||
"access_level": "viewer",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
grant = _body(resp)["grant"]
|
||||
assert grant["owner_id"] == "alice"
|
||||
assert grant["granted_by"] == "bob"
|
||||
|
||||
def test_editor_with_switch_can_change_level(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor")
|
||||
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
with _as("bob"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants",
|
||||
json={"resource_type": "agent", "resource_id": aid, "access_level": "viewer"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert _grants(db, tid)[0]["access_level"] == "viewer"
|
||||
|
||||
def test_viewer_gets_403(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
with _as("bob"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants",
|
||||
json={"resource_type": "agent", "resource_id": aid, "access_level": "editor"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_stranger_resource_is_404(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db, owner="mallory")
|
||||
with _as("bob"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants", json={"resource_type": "agent", "resource_id": aid}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert _grants(db, tid) == []
|
||||
|
||||
def test_owner_shares(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
with _as("alice"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants",
|
||||
json={"resource_type": "agent", "resource_id": aid, "access_level": "editor"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert _grants(db, tid)[0]["owner_id"] == "alice"
|
||||
|
||||
def test_caller_must_be_member(self, client, db):
|
||||
tid = _team(db, owner="carol", members=())
|
||||
aid = _agent(db)
|
||||
with _as("alice"):
|
||||
resp = client.post(
|
||||
f"/api/teams/{tid}/grants", json={"resource_type": "agent", "resource_id": aid}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- DELETE /api/teams/<tid>/grants -----------------------------------------
|
||||
|
||||
|
||||
class TestUnshare:
|
||||
def test_owner_who_left_can_unshare(self, client, db):
|
||||
tid = _team(db, owner="carol", members=(("alice", "team_member"),))
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
TeamMembersRepository(db).remove_member(tid, "alice")
|
||||
with _as("alice"):
|
||||
resp = client.delete(
|
||||
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _body(resp)["success"] is True
|
||||
assert _grants(db, tid) == []
|
||||
|
||||
def test_team_admin_unshares_someone_elses(self, client, db):
|
||||
tid = _team(db, owner="carol", members=(("alice", "team_member"),))
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
with _as("carol"):
|
||||
resp = client.delete(
|
||||
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _grants(db, tid) == []
|
||||
|
||||
def test_member_without_share_gets_403(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor")
|
||||
with _as("bob"):
|
||||
resp = client.delete(
|
||||
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert len(_grants(db, tid)) == 1
|
||||
|
||||
def test_editor_with_switch_unshares_member_grant(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor", target="bob")
|
||||
_grant(db, tid, aid, "viewer", target="carol")
|
||||
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
with _as("bob"):
|
||||
resp = client.delete(
|
||||
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}&target_user_id=carol"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert [g["target_user_id"] for g in _grants(db, tid)] == ["bob"]
|
||||
|
||||
def test_outsider_without_share_gets_403(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
with _as("mallory"):
|
||||
resp = client.delete(
|
||||
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- GET /api/resource_shares -----------------------------------------------
|
||||
|
||||
|
||||
class TestResourceShares:
|
||||
def test_viewer_gets_403(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
with _as("bob"):
|
||||
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_editor_with_switch_lists(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor")
|
||||
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
with _as("bob"):
|
||||
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 200
|
||||
assert len(_body(resp)["shares"]) == 1
|
||||
|
||||
def test_owner_lists(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
with _as("alice"):
|
||||
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 200
|
||||
assert _body(resp)["shares"][0]["team_name"] == "Acme"
|
||||
|
||||
def test_stranger_404(self, client, db):
|
||||
aid = _agent(db)
|
||||
with _as("mallory"):
|
||||
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- GET/PUT /api/resource_settings -----------------------------------------
|
||||
|
||||
|
||||
class TestResourceSettings:
|
||||
def test_owner_get_shape(self, client, db):
|
||||
aid = _agent(db)
|
||||
with _as("alice"):
|
||||
resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 200
|
||||
body = _body(resp)
|
||||
assert body["success"] is True
|
||||
assert body["resource_type"] == "agent"
|
||||
assert body["resource_id"] == aid
|
||||
assert body["access"] == "owner"
|
||||
assert "manage_settings" in body["allowed_actions"]
|
||||
assert body["settings"][0] == {"key": "editors_can_share", "value": False, "default": False}
|
||||
|
||||
def test_viewer_can_read(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
with _as("bob"):
|
||||
resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 200
|
||||
assert _body(resp)["access"] == "viewer"
|
||||
assert _body(resp)["allowed_actions"] == ["pin", "use"]
|
||||
|
||||
def test_stranger_404(self, client, db):
|
||||
aid = _agent(db)
|
||||
with _as("mallory"):
|
||||
resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_bad_type_400(self, client, db):
|
||||
with _as("alice"):
|
||||
resp = client.get(
|
||||
f"/api/resource_settings?resource_type=nope&resource_id={uuid.uuid4()}"
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_owner_put(self, client, db):
|
||||
aid = _agent(db)
|
||||
with _as("alice"):
|
||||
resp = client.put(
|
||||
"/api/resource_settings",
|
||||
json={
|
||||
"resource_type": "agent",
|
||||
"resource_id": aid,
|
||||
"settings": {"editors_can_share": True},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = _body(resp)
|
||||
assert body["settings"][0]["value"] is True
|
||||
assert body["access"] == "owner"
|
||||
assert settings_for(db, "agent", aid)["editors_can_share"] is True
|
||||
|
||||
def test_put_reflects_new_actions_for_prompt(self, client, db):
|
||||
pid = str(PromptsRepository(db).create("alice", "P", "text")["id"])
|
||||
with _as("alice"):
|
||||
resp = client.put(
|
||||
"/api/resource_settings",
|
||||
json={
|
||||
"resource_type": "prompt",
|
||||
"resource_id": pid,
|
||||
"settings": {"viewers_can_duplicate": False},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert {s["key"]: s["value"] for s in _body(resp)["settings"]}["viewers_can_duplicate"] is False
|
||||
|
||||
def test_put_unknown_key_400(self, client, db):
|
||||
aid = _agent(db)
|
||||
with _as("alice"):
|
||||
resp = client.put(
|
||||
"/api/resource_settings",
|
||||
json={"resource_type": "agent", "resource_id": aid, "settings": {"nope": True}},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_put_non_bool_400(self, client, db):
|
||||
aid = _agent(db)
|
||||
with _as("alice"):
|
||||
resp = client.put(
|
||||
"/api/resource_settings",
|
||||
json={
|
||||
"resource_type": "agent",
|
||||
"resource_id": aid,
|
||||
"settings": {"editors_can_share": "yes"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_put_bad_type_400(self, client, db):
|
||||
with _as("alice"):
|
||||
resp = client.put(
|
||||
"/api/resource_settings",
|
||||
json={"resource_type": "nope", "resource_id": str(uuid.uuid4()), "settings": {}},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_editor_put_403(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "editor")
|
||||
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
with _as("bob"):
|
||||
resp = client.put(
|
||||
"/api/resource_settings",
|
||||
json={
|
||||
"resource_type": "agent",
|
||||
"resource_id": aid,
|
||||
"settings": {"editors_can_delete": True},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert settings_for(db, "agent", aid)["editors_can_delete"] is False
|
||||
|
||||
|
||||
# --- GET /api/teams/<tid>/grants --------------------------------------------
|
||||
|
||||
|
||||
class TestGrantListing:
|
||||
def test_rows_are_enriched(self, client, db):
|
||||
UsersRepository(db).upsert("alice", email="alice@example.com")
|
||||
UsersRepository(db).upsert("bob", email="bob@example.com")
|
||||
tid = _team(db)
|
||||
aid = _agent(db, name="Support bot")
|
||||
tool = UserToolsRepository(db).create("alice", "api_tool", custom_name="CRM")
|
||||
_grant(db, tid, aid, "editor", target="bob")
|
||||
_grant(db, tid, str(tool["id"]), "viewer", rtype="tool")
|
||||
with _as("bob"):
|
||||
resp = client.get(f"/api/teams/{tid}/grants")
|
||||
assert resp.status_code == 200
|
||||
body = _body(resp)
|
||||
assert body["team_role"] == "team_member"
|
||||
by_type = {g["resource_type"]: g for g in body["grants"]}
|
||||
agent_row = by_type["agent"]
|
||||
assert agent_row["resource_name"] == "Support bot"
|
||||
assert agent_row["owner_id"] == "alice"
|
||||
assert agent_row["owner_label"] == "alice@example.com"
|
||||
assert agent_row["target_user_label"] == "bob@example.com"
|
||||
assert agent_row["granted_by_label"] == "alice@example.com"
|
||||
assert agent_row["created_at"]
|
||||
assert agent_row["access_level"] == "editor"
|
||||
assert agent_row["caller"]["access"] == "editor"
|
||||
assert "edit" in agent_row["caller"]["allowed_actions"]
|
||||
tool_row = by_type["tool"]
|
||||
assert tool_row["resource_name"] == "CRM"
|
||||
assert tool_row["target_user_label"] is None
|
||||
assert tool_row["caller"] == {
|
||||
"access": "viewer",
|
||||
"allowed_actions": ["use", "use_in_own"],
|
||||
}
|
||||
|
||||
def test_owner_caller_and_missing_labels(self, client, db):
|
||||
tid = _team(db)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer")
|
||||
with _as("alice"):
|
||||
body = _body(client.get(f"/api/teams/{tid}/grants"))
|
||||
row = body["grants"][0]
|
||||
assert body["team_role"] == "team_admin"
|
||||
assert row["caller"]["access"] == "owner"
|
||||
assert row["owner_label"] is None
|
||||
|
||||
def test_member_does_not_see_others_personal_grants(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
|
||||
a1, a2, a3 = _agent(db, name="Team"), _agent(db, name="Bob"), _agent(db, name="Carol")
|
||||
_grant(db, tid, a1, "viewer")
|
||||
_grant(db, tid, a2, "viewer", target="bob")
|
||||
_grant(db, tid, a3, "viewer", target="carol")
|
||||
with _as("bob"):
|
||||
names = {g["resource_name"] for g in _body(client.get(f"/api/teams/{tid}/grants"))["grants"]}
|
||||
assert names == {"Team", "Bob"}
|
||||
|
||||
def test_admin_and_sharer_see_all(self, client, db):
|
||||
tid = _team(
|
||||
db,
|
||||
owner="carol",
|
||||
members=(("alice", "team_member"), ("bob", "team_member"), ("dave", "team_member")),
|
||||
)
|
||||
aid = _agent(db)
|
||||
_grant(db, tid, aid, "viewer", target="bob")
|
||||
with _as("carol"): # team_admin
|
||||
assert len(_body(client.get(f"/api/teams/{tid}/grants"))["grants"]) == 1
|
||||
with _as("alice"): # owner of the resource (has share)
|
||||
assert len(_body(client.get(f"/api/teams/{tid}/grants"))["grants"]) == 1
|
||||
with _as("dave"):
|
||||
assert _body(client.get(f"/api/teams/{tid}/grants"))["grants"] == []
|
||||
|
||||
def test_resource_type_filter_kept(self, client, db):
|
||||
tid = _team(db)
|
||||
_grant(db, tid, _agent(db), "viewer")
|
||||
pid = str(PromptsRepository(db).create("alice", "P", "text")["id"])
|
||||
_grant(db, tid, pid, "viewer", rtype="prompt")
|
||||
with _as("bob"):
|
||||
body = _body(client.get(f"/api/teams/{tid}/grants?resource_type=prompt"))
|
||||
assert [g["resource_name"] for g in body["grants"]] == ["P"]
|
||||
|
||||
|
||||
# --- Team owner protection --------------------------------------------------
|
||||
|
||||
|
||||
class TestTeamOwnerProtection:
|
||||
def test_admin_cannot_demote_owner(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_admin"),))
|
||||
with _as("bob"):
|
||||
resp = client.put(f"/api/teams/{tid}/members/alice", json={"role": "team_member"})
|
||||
assert resp.status_code == 403
|
||||
assert TeamMembersRepository(db).role_for("alice", tid) == "team_admin"
|
||||
|
||||
def test_admin_cannot_remove_owner(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_admin"),))
|
||||
with _as("bob"):
|
||||
resp = client.delete(f"/api/teams/{tid}/members/alice")
|
||||
assert resp.status_code == 403
|
||||
assert TeamMembersRepository(db).is_member("alice", tid)
|
||||
|
||||
def test_owner_must_transfer_before_leaving(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_admin"),))
|
||||
with _as("alice"):
|
||||
resp = client.delete(f"/api/teams/{tid}/members/alice")
|
||||
assert resp.status_code == 400
|
||||
assert "transfer" in _body(resp)["message"].lower()
|
||||
assert TeamMembersRepository(db).is_member("alice", tid)
|
||||
|
||||
def test_owner_cannot_self_demote(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_admin"),))
|
||||
with _as("alice"):
|
||||
resp = client.put(f"/api/teams/{tid}/members/alice", json={"role": "team_member"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_admin_can_still_manage_other_admins(self, client, db):
|
||||
tid = _team(db, members=(("bob", "team_admin"), ("carol", "team_admin")))
|
||||
with _as("bob"):
|
||||
resp = client.put(f"/api/teams/{tid}/members/carol", json={"role": "team_member"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_team_payloads_carry_is_owner(self, client, db):
|
||||
tid = _team(db)
|
||||
with _as("alice"):
|
||||
listed = _body(client.get("/api/teams"))["teams"]
|
||||
detail = _body(client.get(f"/api/teams/{tid}"))["team"]
|
||||
assert listed[0]["is_owner"] is True
|
||||
assert detail["is_owner"] is True
|
||||
assert detail["owner_id"] == "alice"
|
||||
with _as("bob"):
|
||||
assert _body(client.get("/api/teams"))["teams"][0]["is_owner"] is False
|
||||
assert _body(client.get(f"/api/teams/{tid}"))["team"]["is_owner"] is False
|
||||
@@ -319,9 +319,9 @@ class TestGetSinglePromptHappyPath:
|
||||
from docsgpt.api.user.prompts.routes import GetSinglePrompt
|
||||
|
||||
# Presets are composed in-process now, so the failure this covers is a
|
||||
# repository error on the custom-prompt path.
|
||||
# database error on the custom-prompt path (the access check runs first).
|
||||
with patch(
|
||||
"docsgpt.api.user.prompts.routes.PromptsRepository",
|
||||
"docsgpt.api.user.prompts.routes.require",
|
||||
side_effect=OSError("boom"),
|
||||
), app.test_request_context("/api/get_single_prompt?id=some-custom-id"):
|
||||
from flask import request
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Roles on prompts: listing payload, get, update, delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.api.user.resource_access import set_settings
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
OWNER = "alice"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch("docsgpt.api.user.prompts.routes.db_session", _yield), patch(
|
||||
"docsgpt.api.user.prompts.routes.db_readonly", _yield
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
|
||||
with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
return getattr(resource_cls(), method)()
|
||||
|
||||
|
||||
def _prompt(conn, name="P", content="C"):
|
||||
return PromptsRepository(conn).create(OWNER, name, content)
|
||||
|
||||
|
||||
def _share(conn, prompt_id, member, level):
|
||||
team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
|
||||
TeamMembersRepository(conn).add_member(str(team["id"]), member)
|
||||
TeamResourceGrantsRepository(conn).grant(
|
||||
str(team["id"]), "prompt", str(prompt_id), OWNER, OWNER, access_level=level
|
||||
)
|
||||
|
||||
|
||||
class TestGetPromptsAccess:
|
||||
def _list(self, app, conn, user):
|
||||
from docsgpt.api.user.prompts.routes import GetPrompts
|
||||
|
||||
resp = _call(app, conn, GetPrompts, user, method="get", path="/api/get_prompts")
|
||||
assert resp.status_code == 200
|
||||
return {p["id"]: p for p in resp.json}
|
||||
|
||||
def test_owner_and_grantee_payloads(self, app, pg_conn):
|
||||
prompt = _prompt(pg_conn)
|
||||
pid = str(prompt["id"])
|
||||
_share(pg_conn, pid, "bob", "viewer")
|
||||
own = self._list(app, pg_conn, OWNER)[pid]
|
||||
assert own["access"] == "owner" and "delete" in own["allowed_actions"]
|
||||
assert own["updated_at"]
|
||||
shared = self._list(app, pg_conn, "bob")[pid]
|
||||
assert shared["access"] == "viewer"
|
||||
assert shared["allowed_actions"] == ["duplicate", "use"]
|
||||
assert shared["type"] == "team" and shared["team_access"] == "viewer"
|
||||
assert shared["updated_at"]
|
||||
|
||||
def test_presets_have_no_access_fields(self, app, pg_conn):
|
||||
presets = self._list(app, pg_conn, OWNER)
|
||||
assert "access" not in presets["default"]
|
||||
|
||||
|
||||
class TestGetSinglePromptAccess:
|
||||
def test_by_role(self, app, pg_conn):
|
||||
from docsgpt.api.user.prompts.routes import GetSinglePrompt
|
||||
|
||||
prompt = _prompt(pg_conn, content="Hello")
|
||||
pid = str(prompt["id"])
|
||||
_share(pg_conn, pid, "bob", "viewer")
|
||||
for user, access in ((OWNER, "owner"), ("bob", "viewer")):
|
||||
resp = _call(app, pg_conn, GetSinglePrompt, user, method="get", path=f"/api/get_single_prompt?id={pid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["content"] == "Hello"
|
||||
assert resp.json["access"] == access
|
||||
assert resp.json["updated_at"]
|
||||
resp = _call(app, pg_conn, GetSinglePrompt, "eve", method="get", path=f"/api/get_single_prompt?id={pid}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestUpdatePromptAccess:
|
||||
def test_by_role(self, app, pg_conn):
|
||||
from docsgpt.api.user.prompts.routes import UpdatePrompt
|
||||
|
||||
prompt = _prompt(pg_conn)
|
||||
pid = str(prompt["id"])
|
||||
_share(pg_conn, pid, "ed", "editor")
|
||||
_share(pg_conn, pid, "vi", "viewer")
|
||||
body = {"id": pid, "name": "N", "content": "by editor"}
|
||||
assert _call(app, pg_conn, UpdatePrompt, "ed", json=body).status_code == 200
|
||||
assert PromptsRepository(pg_conn).get(pid, OWNER)["content"] == "by editor"
|
||||
resp = _call(app, pg_conn, UpdatePrompt, "vi", json=body)
|
||||
assert resp.status_code == 403 and resp.json["success"] is False
|
||||
assert _call(app, pg_conn, UpdatePrompt, "eve", json=body).status_code == 404
|
||||
|
||||
def test_stale_write_is_409_for_owner_too(self, app, pg_conn):
|
||||
from docsgpt.api.user.prompts.routes import UpdatePrompt
|
||||
|
||||
prompt = _prompt(pg_conn)
|
||||
body = {"id": str(prompt["id"]), "name": "N", "content": "x",
|
||||
"expected_updated_at": "2000-01-01T00:00:00+00:00"}
|
||||
resp = _call(app, pg_conn, UpdatePrompt, OWNER, json=body)
|
||||
assert resp.status_code == 409 and resp.json["code"] == "stale_write"
|
||||
|
||||
|
||||
class TestDeletePromptAccess:
|
||||
def test_by_role_and_cleanup(self, app, pg_conn):
|
||||
from docsgpt.api.user.prompts.routes import DeletePrompt
|
||||
|
||||
prompt = _prompt(pg_conn)
|
||||
pid = str(prompt["id"])
|
||||
_share(pg_conn, pid, "ed", "editor")
|
||||
set_settings(pg_conn, "prompt", pid, {"editors_can_share": True}, OWNER)
|
||||
assert _call(app, pg_conn, DeletePrompt, "ed", json={"id": pid}).status_code == 403
|
||||
assert _call(app, pg_conn, DeletePrompt, "eve", json={"id": pid}).status_code == 404
|
||||
assert _call(app, pg_conn, DeletePrompt, OWNER, json={"id": pid}).status_code == 200
|
||||
assert PromptsRepository(pg_conn).get(pid, OWNER) is None
|
||||
for table in ("team_resource_grants", "resource_share_settings"):
|
||||
count = pg_conn.execute(
|
||||
text(f"SELECT count(*) FROM {table} WHERE resource_id = CAST(:id AS uuid)"), {"id": pid}
|
||||
).scalar()
|
||||
assert count == 0
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for the single resource-access check (roles + per-asset switches)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
allowed_actions,
|
||||
public_settings,
|
||||
require,
|
||||
resolve,
|
||||
set_settings,
|
||||
settings_for,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
|
||||
def _team(conn, owner="alice"):
|
||||
return TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
|
||||
|
||||
|
||||
def _share(conn, team, resource_type, resource_id, level, member="bob", owner="alice"):
|
||||
TeamMembersRepository(conn).add_member(str(team["id"]), member)
|
||||
TeamResourceGrantsRepository(conn).grant(
|
||||
str(team["id"]), resource_type, resource_id, owner, owner, access_level=level
|
||||
)
|
||||
|
||||
|
||||
def _agent(conn, owner="alice"):
|
||||
return str(AgentsRepository(conn).create(owner, "A", "published")["id"])
|
||||
|
||||
|
||||
class TestAllowedActions:
|
||||
def test_owner_gets_everything(self):
|
||||
actions = allowed_actions("agent", "owner", {})
|
||||
assert {"delete", "share", "manage_settings", "move_folder", "edit"} <= actions
|
||||
|
||||
def test_agent_editor_defaults(self):
|
||||
actions = allowed_actions("agent", "editor", {})
|
||||
assert {"use", "edit", "publish", "view_logs", "manage_schedules", "export",
|
||||
"manage_access_details", "edit_policy"} <= actions
|
||||
assert not {"delete", "share", "manage_settings", "move_folder"} & actions
|
||||
|
||||
def test_agent_viewer_defaults(self):
|
||||
actions = allowed_actions("agent", "viewer", {})
|
||||
assert actions == {"use", "pin"}
|
||||
|
||||
def test_switches_widen_and_narrow(self):
|
||||
settings = {
|
||||
"editors_can_share": True,
|
||||
"editors_can_delete": True,
|
||||
"editors_can_manage_access_details": False,
|
||||
"viewers_can_see_logs": True,
|
||||
}
|
||||
editor = allowed_actions("agent", "editor", settings)
|
||||
assert {"share", "delete"} <= editor
|
||||
assert "manage_access_details" not in editor
|
||||
assert "view_logs" in allowed_actions("agent", "viewer", settings)
|
||||
|
||||
def test_switches_never_touch_owner_only_settings(self):
|
||||
# Even with every switch on, managing the switches stays owner-only.
|
||||
settings = {"editors_can_share": True, "editors_can_delete": True}
|
||||
assert "manage_settings" not in allowed_actions("agent", "editor", settings)
|
||||
|
||||
def test_tool_viewer_can_use_in_own_by_default(self):
|
||||
assert "use_in_own" in allowed_actions("tool", "viewer", {})
|
||||
assert "use_in_own" not in allowed_actions(
|
||||
"tool", "viewer", {"viewers_can_use_in_agents": False}
|
||||
)
|
||||
assert "edit_credentials" in allowed_actions("tool", "editor", {})
|
||||
assert "edit_credentials" not in allowed_actions(
|
||||
"tool", "editor", {"editors_can_change_credentials": False}
|
||||
)
|
||||
|
||||
def test_source_and_prompt_defaults(self):
|
||||
assert "view_config" in allowed_actions("source", "viewer", {})
|
||||
assert "edit" not in allowed_actions("source", "viewer", {})
|
||||
assert "edit" in allowed_actions("source", "editor", {})
|
||||
assert "reconnect" not in allowed_actions("source", "editor", {})
|
||||
assert "duplicate" in allowed_actions("prompt", "viewer", {})
|
||||
assert "edit" in allowed_actions("prompt", "editor", {})
|
||||
assert "edit" not in allowed_actions("prompt", "viewer", {})
|
||||
|
||||
def test_no_access_means_nothing(self):
|
||||
assert allowed_actions("agent", None, {}) == set()
|
||||
|
||||
|
||||
class TestSettings:
|
||||
def test_defaults_when_no_row(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
assert settings_for(pg_conn, "agent", aid) == {
|
||||
"editors_can_share": False,
|
||||
"editors_can_delete": False,
|
||||
"editors_can_manage_access_details": True,
|
||||
"viewers_can_see_logs": False,
|
||||
}
|
||||
|
||||
def test_set_and_read_back(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
set_settings(pg_conn, "agent", aid, {"viewers_can_see_logs": True}, "alice")
|
||||
assert settings_for(pg_conn, "agent", aid)["viewers_can_see_logs"] is True
|
||||
# A partial update keeps earlier values.
|
||||
set_settings(pg_conn, "agent", aid, {"editors_can_share": True}, "alice")
|
||||
merged = settings_for(pg_conn, "agent", aid)
|
||||
assert merged["viewers_can_see_logs"] is True
|
||||
assert merged["editors_can_share"] is True
|
||||
|
||||
def test_unknown_key_rejected(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
with pytest.raises(ValueError):
|
||||
set_settings(pg_conn, "agent", aid, {"viewers_can_delete": True}, "alice")
|
||||
with pytest.raises(ValueError):
|
||||
set_settings(pg_conn, "agent", aid, {"editors_can_share": "yes"}, "alice")
|
||||
|
||||
def test_settings_many(self, pg_conn):
|
||||
a1, a2 = _agent(pg_conn), _agent(pg_conn)
|
||||
set_settings(pg_conn, "agent", a1, {"editors_can_delete": True}, "alice")
|
||||
many = settings_many(pg_conn, "agent", [a1, a2, "not-a-uuid"])
|
||||
assert many[a1]["editors_can_delete"] is True
|
||||
assert many[a2]["editors_can_delete"] is False
|
||||
assert many["not-a-uuid"]["editors_can_delete"] is False
|
||||
|
||||
def test_public_settings_shape(self):
|
||||
rows = public_settings("prompt", {"editors_can_share": True})
|
||||
assert [r["key"] for r in rows] == ["editors_can_share", "viewers_can_duplicate"]
|
||||
assert rows[0] == {"key": "editors_can_share", "value": True, "default": False}
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_owner(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
ra = resolve(pg_conn, "agent", aid, "alice")
|
||||
assert ra.access == "owner"
|
||||
assert ra.owner_id == "alice"
|
||||
assert ra.can("delete")
|
||||
|
||||
def test_editor_and_viewer_via_team(self, pg_conn):
|
||||
team = _team(pg_conn)
|
||||
aid = _agent(pg_conn)
|
||||
_share(pg_conn, team, "agent", aid, "editor", member="bob")
|
||||
ra = resolve(pg_conn, "agent", aid, "bob")
|
||||
assert ra.access == "editor"
|
||||
assert ra.owner_id == "alice"
|
||||
assert ra.can("view_logs") and not ra.can("delete")
|
||||
|
||||
aid2 = _agent(pg_conn)
|
||||
_share(pg_conn, team, "agent", aid2, "viewer", member="carol")
|
||||
rv = resolve(pg_conn, "agent", aid2, "carol")
|
||||
assert rv.access == "viewer"
|
||||
assert rv.actions == frozenset({"use", "pin"})
|
||||
|
||||
def test_switch_applies_to_grantee(self, pg_conn):
|
||||
team = _team(pg_conn)
|
||||
aid = _agent(pg_conn)
|
||||
_share(pg_conn, team, "agent", aid, "viewer")
|
||||
set_settings(pg_conn, "agent", aid, {"viewers_can_see_logs": True}, "alice")
|
||||
assert resolve(pg_conn, "agent", aid, "bob").can("view_logs")
|
||||
|
||||
def test_stranger_and_bad_ids(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
assert resolve(pg_conn, "agent", aid, "mallory") is None
|
||||
assert resolve(pg_conn, "agent", "not-a-uuid", "alice") is None
|
||||
assert resolve(pg_conn, "agent", str(uuid.uuid4()), "alice") is None
|
||||
assert resolve(pg_conn, "nope", aid, "alice") is None
|
||||
|
||||
def test_payload(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
payload = resolve(pg_conn, "agent", aid, "alice").payload()
|
||||
assert payload["access"] == "owner"
|
||||
assert "delete" in payload["allowed_actions"]
|
||||
assert payload["allowed_actions"] == sorted(payload["allowed_actions"])
|
||||
|
||||
|
||||
class TestRequire:
|
||||
def test_allowed_returns_access(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
assert require(pg_conn, "agent", aid, "alice", "delete").owner_id == "alice"
|
||||
|
||||
def test_visible_but_not_allowed_is_403(self, pg_conn):
|
||||
team = _team(pg_conn)
|
||||
aid = _agent(pg_conn)
|
||||
_share(pg_conn, team, "agent", aid, "viewer")
|
||||
with pytest.raises(AccessDenied) as exc:
|
||||
require(pg_conn, "agent", aid, "bob", "edit")
|
||||
assert exc.value.status == 403
|
||||
|
||||
def test_invisible_is_404(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
with pytest.raises(AccessDenied) as exc:
|
||||
require(pg_conn, "agent", aid, "mallory", "use")
|
||||
assert exc.value.status == 404
|
||||
|
||||
def test_unknown_action_is_a_bug(self, pg_conn):
|
||||
aid = _agent(pg_conn)
|
||||
with pytest.raises(KeyError):
|
||||
require(pg_conn, "agent", aid, "alice", "fly")
|
||||
@@ -616,3 +616,67 @@ class TestExecuteScheduledRunBody:
|
||||
meta = messages[0]._mapping["message_metadata"]
|
||||
assert meta.get("scheduled") is True
|
||||
assert "schedule.message.appended" in {e[0] for e in stub_events}
|
||||
|
||||
|
||||
class TestRunAsOwnerAccessRecheck:
|
||||
"""A schedule someone set on another user's agent re-checks their access
|
||||
on every run; the run itself always executes as the agent's owner."""
|
||||
|
||||
def _member_run(self, conn, *, grant: bool):
|
||||
import uuid as _uuid
|
||||
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
|
||||
agent_id = _make_agent(conn, "owner-x")
|
||||
if grant:
|
||||
team = TeamsRepository(conn).create("T", f"t-{_uuid.uuid4().hex[:8]}", "owner-x")
|
||||
TeamMembersRepository(conn).add_member(str(team["id"]), "member-y")
|
||||
TeamResourceGrantsRepository(conn).grant(
|
||||
str(team["id"]), "agent", agent_id, "owner-x", "owner-x",
|
||||
access_level="viewer",
|
||||
)
|
||||
schedule = SchedulesRepository(conn).create(
|
||||
user_id="member-y", agent_id=agent_id, trigger_type="recurring",
|
||||
instruction="hello", cron="* * * * *",
|
||||
next_run_at=_now() + timedelta(minutes=5),
|
||||
)
|
||||
run = ScheduleRunsRepository(conn).record_pending(
|
||||
str(schedule["id"]), "member-y", agent_id, _now(),
|
||||
)
|
||||
return run
|
||||
|
||||
def test_revoked_member_run_fails_without_running(
|
||||
self, pg_engine, patched_engine, stub_events,
|
||||
):
|
||||
with pg_engine.begin() as conn:
|
||||
run = self._member_run(conn, grant=False)
|
||||
with patch(
|
||||
"docsgpt.api.user.scheduler_worker.run_agent_headless",
|
||||
) as headless:
|
||||
result = execute_scheduled_run_body(str(run["id"]), "celery-r")
|
||||
headless.assert_not_called()
|
||||
assert result["status"] == "failed"
|
||||
with pg_engine.connect() as conn:
|
||||
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
|
||||
assert row["status"] == "failed"
|
||||
assert row["error"] == "agent access revoked"
|
||||
|
||||
def test_member_with_grant_runs(self, pg_engine, patched_engine, stub_events):
|
||||
with pg_engine.begin() as conn:
|
||||
run = self._member_run(conn, grant=True)
|
||||
with patch(
|
||||
"docsgpt.api.user.scheduler_worker.run_agent_headless",
|
||||
return_value={
|
||||
"answer": "ok", "tool_calls": [], "sources": [], "thought": "",
|
||||
"prompt_tokens": 1, "generated_tokens": 1, "denied": [],
|
||||
"error_type": None, "model_id": "m",
|
||||
},
|
||||
) as headless:
|
||||
result = execute_scheduled_run_body(str(run["id"]), "celery-g")
|
||||
assert result["status"] == "success"
|
||||
# Runs with the owner's agent row (owner's context).
|
||||
assert headless.call_args.args[0]["user_id"] == "owner-x"
|
||||
@@ -493,7 +493,7 @@ class TestUnexpectedExceptionMasked:
|
||||
raise RuntimeError("internal detail: secret connection string")
|
||||
|
||||
with _patch_db(pg_conn), patch.object(
|
||||
SchedulesRepository, "get", side_effect=_boom,
|
||||
SchedulesRepository, "get_internal", side_effect=_boom,
|
||||
), app.test_request_context(
|
||||
f"/api/schedules/{s['id']}", method="GET",
|
||||
):
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
"""Roles on tools: listing payload, write actions, secrets, chat preferences, MCP save."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from docsgpt.api.user.resource_access import set_settings
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
|
||||
OWNER = "alice"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_db(conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield conn
|
||||
|
||||
with patch("docsgpt.api.user.tools.routes.db_session", _yield), patch(
|
||||
"docsgpt.api.user.tools.routes.db_readonly", _yield
|
||||
), patch("docsgpt.api.user.tools.mcp.db_session", _yield), patch(
|
||||
"docsgpt.api.user.tools.mcp.db_readonly", _yield
|
||||
), patch("docsgpt.api.user.tools.routes.validate_url"), patch(
|
||||
"docsgpt.api.user.tools.mcp.validate_url"
|
||||
):
|
||||
# URL validation resolves DNS; the SSRF gate has its own tests.
|
||||
yield
|
||||
|
||||
|
||||
def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
|
||||
with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
|
||||
from flask import request
|
||||
|
||||
request.decoded_token = {"sub": user}
|
||||
return getattr(resource_cls(), method)()
|
||||
|
||||
|
||||
def _share(conn, tool_id, member, level, *, team_name="Acme"):
|
||||
team = TeamsRepository(conn).create(team_name, f"t-{uuid.uuid4().hex[:8]}", OWNER)
|
||||
TeamMembersRepository(conn).add_member(str(team["id"]), member)
|
||||
TeamResourceGrantsRepository(conn).grant(
|
||||
str(team["id"]), "tool", str(tool_id), OWNER, OWNER, access_level=level
|
||||
)
|
||||
return team
|
||||
|
||||
|
||||
def _tool(conn, name="read_webpage", config=None, status=True, owner=OWNER):
|
||||
return UserToolsRepository(conn).create(
|
||||
owner, name, config=config or {}, display_name=name, description="",
|
||||
actions=[{"name": "act", "active": True, "require_approval": False}], status=status,
|
||||
)
|
||||
|
||||
|
||||
def _api_config(url="https://api.example.com/users", header_value="sk-secret", query_value="q-secret"):
|
||||
return {
|
||||
"actions": {
|
||||
"get_users": {
|
||||
"name": "get_users",
|
||||
"description": "Get users",
|
||||
"url": url,
|
||||
"method": "GET",
|
||||
"active": True,
|
||||
"headers": {"type": "object", "properties": {
|
||||
"X-Key": {"type": "string", "description": "k", "value": header_value,
|
||||
"filled_by_llm": False},
|
||||
}},
|
||||
"query_params": {"type": "object", "properties": {
|
||||
"token": {"type": "string", "description": "t", "value": query_value,
|
||||
"filled_by_llm": False},
|
||||
"limit": {"type": "integer", "description": "l", "value": "", "filled_by_llm": True},
|
||||
}},
|
||||
"body": {"type": "object", "properties": {}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _row(conn, tool_id, owner=OWNER):
|
||||
return UserToolsRepository(conn).get(str(tool_id), owner)
|
||||
|
||||
|
||||
def _runtime_action(conn, tool_id, owner=OWNER):
|
||||
from docsgpt.agents.tool_executor import api_tool_action_with_secrets
|
||||
|
||||
row = _row(conn, tool_id, owner)
|
||||
return api_tool_action_with_secrets(row, "get_users", owner)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. GET /api/get_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestGetToolsAccess:
|
||||
def _tools(self, app, conn, user):
|
||||
from docsgpt.api.user.tools.routes import GetTools
|
||||
|
||||
resp = _call(app, conn, GetTools, user, method="get", path="/api/get_tools")
|
||||
assert resp.status_code == 200
|
||||
return {t["id"]: t for t in resp.json["tools"] if not t.get("default") and not t.get("builtin")}
|
||||
|
||||
def test_owner_row_carries_owner_payload_and_in_chat_from_status(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, status=False)
|
||||
row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
|
||||
assert row["access"] == "owner"
|
||||
assert {"delete", "share", "manage_settings", "edit"} <= set(row["allowed_actions"])
|
||||
assert row["allowed_actions"] == sorted(row["allowed_actions"])
|
||||
assert row["in_chat"] is False
|
||||
assert row["ownership"] == "user"
|
||||
|
||||
def test_viewer_row_payload_shared_via_owner_label_and_preference(self, app, pg_conn):
|
||||
UsersRepository(pg_conn).upsert(OWNER, email="alice@example.com")
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "bob", "viewer", team_name="Research")
|
||||
row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
|
||||
assert row["access"] == "viewer"
|
||||
assert row["allowed_actions"] == ["use", "use_in_own"]
|
||||
assert row["in_chat"] is False # sharing never adds to chats
|
||||
assert row["shared_via"] == "Research"
|
||||
assert row["owner_label"] == "alice@example.com"
|
||||
assert row["ownership"] == "team" and row["team_access"] == "viewer"
|
||||
|
||||
UserToolPreferencesRepository(pg_conn).set_in_chat("bob", str(tool["id"]), True)
|
||||
assert self._tools(app, pg_conn, "bob")[str(tool["id"])]["in_chat"] is True
|
||||
|
||||
def test_owner_label_null_without_user_row(self, app, pg_conn):
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "bob", "editor")
|
||||
row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
|
||||
assert row["owner_label"] is None
|
||||
assert "edit" in row["allowed_actions"] and "delete" not in row["allowed_actions"]
|
||||
|
||||
def test_default_rows_carry_in_chat(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import GetTools
|
||||
|
||||
resp = _call(app, pg_conn, GetTools, OWNER, method="get", path="/api/get_tools")
|
||||
defaults = [t for t in resp.json["tools"] if t.get("default")]
|
||||
assert all(t["in_chat"] == t["status"] for t in defaults)
|
||||
|
||||
def test_api_tool_secret_values_masked_for_owner_and_grantee(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import _seal_api_tool_secrets
|
||||
|
||||
sealed = _seal_api_tool_secrets(_api_config(), {}, OWNER)
|
||||
tool = _tool(pg_conn, name="api_tool", config=sealed)
|
||||
_share(pg_conn, tool["id"], "bob", "viewer")
|
||||
for user in (OWNER, "bob"):
|
||||
row = self._tools(app, pg_conn, user)[str(tool["id"])]
|
||||
action = row["config"]["actions"]["get_users"]
|
||||
header = action["headers"]["properties"]["X-Key"]
|
||||
assert header["value"] == "" and header["has_value"] is True
|
||||
token = action["query_params"]["properties"]["token"]
|
||||
assert token["value"] == "" and token["has_value"] is True
|
||||
limit = action["query_params"]["properties"]["limit"]
|
||||
assert limit["value"] == "" and limit.get("has_value") is False
|
||||
assert "encrypted_action_secrets" not in row["config"]
|
||||
assert "encrypted_credentials" not in row["config"]
|
||||
|
||||
def test_legacy_plaintext_api_tool_values_masked(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config=_api_config())
|
||||
row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
|
||||
header = row["config"]["actions"]["get_users"]["headers"]["properties"]["X-Key"]
|
||||
assert header == {**header, "value": "", "has_value": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. api_tool secret storage
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestApiToolSecrets:
|
||||
def _save(self, app, conn, user, tool_id, config):
|
||||
from docsgpt.api.user.tools.routes import UpdateTool
|
||||
|
||||
return _call(app, conn, UpdateTool, user, json={"id": str(tool_id), "config": config})
|
||||
|
||||
def _masked(self, config):
|
||||
import copy
|
||||
|
||||
out = copy.deepcopy(config)
|
||||
for section in ("headers", "query_params"):
|
||||
for spec in out["actions"]["get_users"][section]["properties"].values():
|
||||
spec["has_value"] = bool(spec.get("value"))
|
||||
spec["value"] = ""
|
||||
return out
|
||||
|
||||
def test_values_stored_encrypted_not_plaintext(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config={})
|
||||
assert self._save(app, pg_conn, OWNER, tool["id"], _api_config()).status_code == 200
|
||||
stored = _row(pg_conn, tool["id"])["config"]
|
||||
header = stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]
|
||||
assert header["value"] == ""
|
||||
assert stored["encrypted_action_secrets"]
|
||||
blob = decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
|
||||
assert blob["get_users"]["headers"]["X-Key"] == "sk-secret"
|
||||
action = _runtime_action(pg_conn, tool["id"])
|
||||
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
|
||||
assert action["query_params"]["properties"]["token"]["value"] == "q-secret"
|
||||
|
||||
def test_empty_value_with_has_value_keeps_stored_secret(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config={})
|
||||
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
|
||||
masked = self._masked(_api_config())
|
||||
masked["actions"]["get_users"]["description"] = "Renamed"
|
||||
assert self._save(app, pg_conn, OWNER, tool["id"], masked).status_code == 200
|
||||
action = _runtime_action(pg_conn, tool["id"])
|
||||
assert action["description"] == "Renamed"
|
||||
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
|
||||
|
||||
def test_new_value_replaces_and_cleared_has_value_drops(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config={})
|
||||
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
|
||||
cfg = self._masked(_api_config())
|
||||
cfg["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] = "sk-new"
|
||||
cfg["actions"]["get_users"]["query_params"]["properties"]["token"]["has_value"] = False
|
||||
self._save(app, pg_conn, OWNER, tool["id"], cfg)
|
||||
action = _runtime_action(pg_conn, tool["id"])
|
||||
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-new"
|
||||
assert action["query_params"]["properties"]["token"]["value"] == ""
|
||||
|
||||
def test_legacy_plaintext_reencrypted_on_save(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config=_api_config())
|
||||
# Runtime reads the legacy plaintext value transparently.
|
||||
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
|
||||
self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config()))
|
||||
stored = _row(pg_conn, tool["id"])["config"]
|
||||
assert stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] == ""
|
||||
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
|
||||
|
||||
def test_editor_save_encrypts_with_owner_key(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config={})
|
||||
_share(pg_conn, tool["id"], "bob", "editor")
|
||||
assert self._save(app, pg_conn, "bob", tool["id"], _api_config()).status_code == 200
|
||||
stored = _row(pg_conn, tool["id"])["config"]
|
||||
assert decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
|
||||
assert UserToolsRepository(pg_conn).list_for_user("bob") == []
|
||||
|
||||
def test_url_host_change_clears_secrets_and_requires_new_ones(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config={})
|
||||
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
|
||||
moved = self._masked(_api_config(url="https://evil.example.org/users"))
|
||||
resp = self._save(app, pg_conn, OWNER, tool["id"], moved)
|
||||
assert resp.status_code == 400
|
||||
assert "credentials" in resp.json["message"].lower()
|
||||
# Same host, different path: stored values kept.
|
||||
same_host = self._masked(_api_config(url="https://api.example.com/v2/users"))
|
||||
assert self._save(app, pg_conn, OWNER, tool["id"], same_host).status_code == 200
|
||||
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
|
||||
|
||||
def test_url_host_change_with_new_secrets_succeeds(self, app, pg_conn):
|
||||
tool = _tool(pg_conn, name="api_tool", config={})
|
||||
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
|
||||
moved = _api_config(url="https://other.example.org/users", header_value="sk-2", query_value="q-2")
|
||||
assert self._save(app, pg_conn, OWNER, tool["id"], moved).status_code == 200
|
||||
action = _runtime_action(pg_conn, tool["id"])
|
||||
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Write actions
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestToolWrites:
|
||||
def test_update_tool_rename_by_role(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateTool
|
||||
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
_share(pg_conn, tool["id"], "vi", "viewer")
|
||||
body = {"id": str(tool["id"]), "customName": "Renamed"}
|
||||
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
|
||||
assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
|
||||
resp = _call(app, pg_conn, UpdateTool, "vi", json=body)
|
||||
assert resp.status_code == 403 and resp.json["success"] is False
|
||||
assert _call(app, pg_conn, UpdateTool, "stranger", json=body).status_code == 404
|
||||
|
||||
def test_update_tool_config_needs_edit_credentials(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateTool, UpdateToolConfig
|
||||
|
||||
tool = _tool(pg_conn, name="brave", config={})
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
|
||||
body = {"id": str(tool["id"]), "config": {"token": "x"}}
|
||||
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
|
||||
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
|
||||
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": True}, OWNER)
|
||||
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
|
||||
|
||||
def test_update_tool_config_encrypts_with_owner_key(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateToolConfig
|
||||
|
||||
tool = _tool(pg_conn, name="mcp_tool", config={"server_url": "https://mcp.example.com/x",
|
||||
"auth_type": "bearer"})
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/x",
|
||||
"auth_type": "bearer", "bearer_token": "tok"}}
|
||||
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
|
||||
stored = _row(pg_conn, tool["id"])["config"]
|
||||
assert decrypt_credentials(stored["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
|
||||
|
||||
def test_update_tool_config_host_change_clears_stored_secrets(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateToolConfig
|
||||
|
||||
cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
|
||||
"encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
|
||||
tool = _tool(pg_conn, name="mcp_tool", config=cfg)
|
||||
body = {"id": str(tool["id"]), "config": {"server_url": "https://other.example.org/x",
|
||||
"auth_type": "bearer"}}
|
||||
resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
|
||||
assert resp.status_code == 400
|
||||
assert "credentials" in resp.json["message"].lower()
|
||||
stored = _row(pg_conn, tool["id"])["config"]
|
||||
assert stored["server_url"] == "https://mcp.example.com/x"
|
||||
|
||||
def test_api_tool_description_edit_needs_only_edit(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateTool, _seal_api_tool_secrets
|
||||
|
||||
tool = _tool(pg_conn, name="api_tool", config=_seal_api_tool_secrets(_api_config(), {}, OWNER))
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
|
||||
cfg = TestApiToolSecrets()._masked(_api_config())
|
||||
cfg["actions"]["get_users"]["description"] = "Edited"
|
||||
body = {"id": str(tool["id"]), "config": cfg}
|
||||
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
|
||||
# Changing the URL (or a secret value) needs edit_credentials.
|
||||
cfg["actions"]["get_users"]["url"] = "https://api.example.com/v2"
|
||||
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
|
||||
|
||||
def test_update_tool_actions_by_role(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateToolActions
|
||||
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
_share(pg_conn, tool["id"], "vi", "viewer")
|
||||
actions = [{"name": "act", "active": True, "require_approval": True}]
|
||||
body = {"id": str(tool["id"]), "actions": actions}
|
||||
assert _call(app, pg_conn, UpdateToolActions, "ed", json=body).status_code == 200
|
||||
assert _row(pg_conn, tool["id"])["actions"][0]["require_approval"] is True
|
||||
assert _call(app, pg_conn, UpdateToolActions, "vi", json=body).status_code == 403
|
||||
assert _call(app, pg_conn, UpdateToolActions, "nobody", json=body).status_code == 404
|
||||
|
||||
def test_delete_tool_by_role_cleans_grants_and_settings(self, app, pg_conn):
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.api.user.tools.routes import DeleteTool
|
||||
|
||||
tool = _tool(pg_conn)
|
||||
tid = str(tool["id"])
|
||||
_share(pg_conn, tid, "ed", "editor")
|
||||
set_settings(pg_conn, "tool", tid, {"editors_can_share": True}, OWNER)
|
||||
assert _call(app, pg_conn, DeleteTool, "ed", json={"id": tid}).status_code == 403
|
||||
assert _call(app, pg_conn, DeleteTool, "nobody", json={"id": tid}).status_code == 404
|
||||
assert _call(app, pg_conn, DeleteTool, OWNER, json={"id": tid}).status_code == 200
|
||||
assert _row(pg_conn, tid) is None
|
||||
grants = pg_conn.execute(
|
||||
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
|
||||
).scalar()
|
||||
settings_rows = pg_conn.execute(
|
||||
text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
|
||||
).scalar()
|
||||
assert grants == 0 and settings_rows == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST /api/update_tool_status
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestUpdateToolStatusRoles:
|
||||
def test_owner_writes_status(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateToolStatus
|
||||
|
||||
tool = _tool(pg_conn, status=True)
|
||||
body = {"id": str(tool["id"]), "status": False}
|
||||
assert _call(app, pg_conn, UpdateToolStatus, OWNER, json=body).status_code == 200
|
||||
assert _row(pg_conn, tool["id"])["status"] is False
|
||||
|
||||
def test_grantee_writes_personal_preference(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateToolStatus
|
||||
|
||||
tool = _tool(pg_conn, status=False)
|
||||
_share(pg_conn, tool["id"], "bob", "viewer")
|
||||
body = {"id": str(tool["id"]), "status": True}
|
||||
assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 200
|
||||
assert _row(pg_conn, tool["id"])["status"] is False # owner's switch untouched
|
||||
prefs = UserToolPreferencesRepository(pg_conn)
|
||||
assert prefs.in_chat_many("bob", [str(tool["id"])]) == {str(tool["id"]): True}
|
||||
|
||||
def test_grantee_without_use_in_own_is_forbidden(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.routes import UpdateToolStatus
|
||||
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "bob", "viewer")
|
||||
set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER)
|
||||
body = {"id": str(tool["id"]), "status": True}
|
||||
assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 403
|
||||
assert _call(app, pg_conn, UpdateToolStatus, "nobody", json=body).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. MCP save
|
||||
# ---------------------------------------------------------------------------
|
||||
def _mcp_tool(conn, url="https://mcp.example.com/mcp", secrets=None, auth_type="bearer"):
|
||||
cfg = {"server_url": url, "auth_type": auth_type, "transport_type": "http"}
|
||||
if secrets:
|
||||
cfg["encrypted_credentials"] = encrypt_credentials(secrets, OWNER)
|
||||
return UserToolsRepository(conn).create(
|
||||
OWNER, "mcp_tool", config=cfg, display_name="M", custom_name="M", description="",
|
||||
actions=[{"name": "old", "active": True}], status=True,
|
||||
)
|
||||
|
||||
|
||||
class TestMCPSaveAccess:
|
||||
def _save(self, app, conn, user, body, discovered=None):
|
||||
from docsgpt.api.user.tools.mcp import MCPServerSave
|
||||
|
||||
fake = MagicMock()
|
||||
fake.get_actions_metadata.return_value = discovered or [{"name": "t1"}]
|
||||
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
|
||||
resp = _call(app, conn, MCPServerSave, user, json=body)
|
||||
return resp, cls
|
||||
|
||||
def _count(self, conn):
|
||||
from sqlalchemy import text
|
||||
|
||||
return conn.execute(text("SELECT count(*) FROM user_tools WHERE name = 'mcp_tool'")).scalar()
|
||||
|
||||
def test_editor_updates_owner_row_no_duplicate(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
body = {"id": str(tool["id"]), "displayName": "Renamed",
|
||||
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
|
||||
"transport_type": "http"}}
|
||||
resp, cls = self._save(app, pg_conn, "ed", body)
|
||||
assert resp.status_code == 200, resp.json
|
||||
assert resp.json["id"] == str(tool["id"])
|
||||
assert self._count(pg_conn) == 1
|
||||
row = _row(pg_conn, tool["id"])
|
||||
assert row["display_name"] == "Renamed"
|
||||
# Stored creds were reused for discovery and kept, encrypted with the owner's key.
|
||||
assert cls.call_args.kwargs.get("user_id") == OWNER
|
||||
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
|
||||
assert decrypt_credentials(row["config"]["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
|
||||
|
||||
def test_viewer_and_stranger_cannot_save_or_duplicate(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
_share(pg_conn, tool["id"], "vi", "viewer")
|
||||
body = {"id": str(tool["id"]), "displayName": "X",
|
||||
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
|
||||
"transport_type": "http", "bearer_token": "new"}}
|
||||
assert self._save(app, pg_conn, "vi", body)[0].status_code == 403
|
||||
assert self._save(app, pg_conn, "nobody", body)[0].status_code == 404
|
||||
assert self._count(pg_conn) == 1
|
||||
|
||||
def test_host_change_without_credentials_is_rejected(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
body = {"id": str(tool["id"]), "displayName": "M",
|
||||
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
|
||||
"transport_type": "http"}}
|
||||
resp, cls = self._save(app, pg_conn, OWNER, body)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["message"] == "Enter credentials for the new server"
|
||||
assert not cls.called
|
||||
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
|
||||
|
||||
def test_host_change_with_new_credentials_replaces_them(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
body = {"id": str(tool["id"]), "displayName": "M",
|
||||
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
|
||||
"transport_type": "http", "bearer_token": "tok2"}}
|
||||
resp, _ = self._save(app, pg_conn, OWNER, body)
|
||||
assert resp.status_code == 200
|
||||
row = _row(pg_conn, tool["id"])
|
||||
assert decrypt_credentials(row["config"]["encrypted_credentials"], OWNER) == {"bearer_token": "tok2"}
|
||||
|
||||
def test_editor_blocked_when_credentials_switch_off(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
|
||||
body = {"id": str(tool["id"]), "displayName": "M",
|
||||
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
|
||||
"transport_type": "http"}}
|
||||
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
|
||||
|
||||
def test_editor_oauth_reconnect_is_owner_only(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, auth_type="oauth")
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
body = {"id": str(tool["id"]), "displayName": "M",
|
||||
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
|
||||
"transport_type": "http", "oauth_task_id": "task-1"}}
|
||||
resp, _ = self._save(app, pg_conn, "ed", body)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_editor_oauth_edit_without_reconnect_keeps_actions(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, auth_type="oauth")
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
body = {"id": str(tool["id"]), "displayName": "Renamed",
|
||||
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
|
||||
"transport_type": "http"}}
|
||||
resp, _ = self._save(app, pg_conn, "ed", body)
|
||||
assert resp.status_code == 200, resp.json
|
||||
row = _row(pg_conn, tool["id"])
|
||||
assert row["display_name"] == "Renamed"
|
||||
assert [a["name"] for a in row["actions"]] == ["old"]
|
||||
|
||||
def test_auth_status_includes_team_mcp_tools(self, app, pg_conn):
|
||||
from docsgpt.api.user.tools.mcp import MCPAuthStatus
|
||||
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
_share(pg_conn, tool["id"], "vi", "viewer")
|
||||
resp = _call(app, pg_conn, MCPAuthStatus, "vi", method="get", path="/api/mcp_server/auth_status")
|
||||
assert resp.json["statuses"] == {str(tool["id"]): "configured"}
|
||||
|
||||
|
||||
class TestMCPTestEndpointAccess:
|
||||
def _test(self, app, conn, user, body):
|
||||
from docsgpt.api.user.tools.mcp import TestMCPServerConfig
|
||||
|
||||
fake = MagicMock()
|
||||
fake.test_connection.return_value = {"success": True, "tools_count": 1, "tools": []}
|
||||
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
|
||||
resp = _call(app, conn, TestMCPServerConfig, user, json=body)
|
||||
return resp, cls
|
||||
|
||||
def _body(self, tool, url="https://mcp.example.com/mcp", **extra):
|
||||
return {"id": str(tool["id"]), "config": {"server_url": url, "auth_type": "bearer",
|
||||
"transport_type": "http", **extra}}
|
||||
|
||||
def test_empty_secret_uses_stored_one_as_owner(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
resp, cls = self._test(app, pg_conn, "ed", self._body(tool))
|
||||
assert resp.status_code == 200 and resp.json["success"] is True
|
||||
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
|
||||
assert cls.call_args.kwargs["user_id"] == OWNER
|
||||
|
||||
def test_host_change_without_secret_is_400(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp"))
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["message"] == "Enter credentials for the new server"
|
||||
assert not cls.called
|
||||
resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp",
|
||||
bearer_token="t2"))
|
||||
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "t2"}
|
||||
|
||||
def test_viewer_and_stranger_denied(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
|
||||
_share(pg_conn, tool["id"], "vi", "viewer")
|
||||
assert self._test(app, pg_conn, "vi", self._body(tool))[0].status_code == 403
|
||||
assert self._test(app, pg_conn, "eve", self._body(tool))[0].status_code == 404
|
||||
|
||||
def test_editor_oauth_test_is_owner_only(self, app, pg_conn):
|
||||
tool = _mcp_tool(pg_conn, auth_type="oauth")
|
||||
_share(pg_conn, tool["id"], "ed", "editor")
|
||||
body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/mcp",
|
||||
"auth_type": "oauth", "transport_type": "http"}}
|
||||
resp, cls = self._test(app, pg_conn, "ed", body)
|
||||
assert resp.status_code == 403 and not cls.called
|
||||
@@ -474,20 +474,24 @@ class TestWorkflowDetailPut:
|
||||
|
||||
def test_validation_failure_returns_400(self, app, pg_conn):
|
||||
from docsgpt.api.user.workflows.routes import WorkflowDetail
|
||||
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
|
||||
|
||||
# Access is checked before the graph is validated (validation runs as
|
||||
# the workflow's owner), so the workflow has to exist.
|
||||
wid = str(WorkflowsRepository(pg_conn).create("u1", "wf")["id"])
|
||||
body = {
|
||||
"name": "bad",
|
||||
"nodes": [{"id": "end1", "type": "end"}],
|
||||
"edges": [],
|
||||
}
|
||||
with _patch_wf_db(pg_conn), app.test_request_context(
|
||||
"/api/workflows/abc",
|
||||
f"/api/workflows/{wid}",
|
||||
method="PUT",
|
||||
json=body,
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": "u1"}
|
||||
response = WorkflowDetail().put("abc")
|
||||
response = WorkflowDetail().put(wid)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_updates_workflow(self, app, pg_conn):
|
||||
|
||||
Reference in new issue
Block a user