Files
DocsGPT/tests/api/test_agent_team_sharing.py
T
arc53-machine 335241e2e6 Ask before an editor's resource runs with their access in someone else's agent
Sponsoring a tool, source or prompt the agent's owner can't use now takes
owning it or having edit access to it; use access alone no longer extends it
to the agent's audience. A save that would make the caller a new sponsor is
refused with 409 sponsor_confirmation_required (the resources and the
agent's audience) until it is retried with confirm_sponsor listing them.
When a sponsor loses access, the resource stops instead of passing to
whoever saves next; another editor takes it over only by confirming.
Workflows follow the same rules. sponsor_details now reports each
sponsorship's state, the reason it stopped, and whether the reader can
take it over.
2026-09-29 16:27:54 +01:00

314 lines
11 KiB
Python

"""Endpoint tests for agent team-sharing paths.
Covers the GET /api/get_agent read/fallback + name-resolution paths and the
PUT /api/update_agent team-editor save-gate.
"""
from __future__ import annotations
import json
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
@pytest.fixture
def client():
from docsgpt.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
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 = []
return [
patch("docsgpt.app.handle_auth", return_value={"sub": sub}),
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),
*_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(
"docsgpt.api.user.agents.routes.resolve_prompt_name",
return_value=prompt_name,
),
patch(
"docsgpt.api.user.agents.routes.resolve_source_details",
return_value=source_details,
),
]
def _run(patches, client, agent_id):
for p in patches:
p.start()
try:
return client.get(f"/api/get_agent?id={agent_id}")
finally:
for p in reversed(patches):
p.stop()
@pytest.mark.unit
class TestGetAgentTeamFallback:
def test_owner_sees_own_agent(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = {"id": aid, "name": "Mine", "status": "published"}
resp = _run(_patches("alice", repo, None), client, aid)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "user"
assert data["team_access"] is None
assert data["access"] == "owner"
def test_team_member_sees_shared_agent(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None # not the owner
repo.get_by_id.return_value = {
"id": aid,
"name": "Shared",
"status": "published",
# Owner secrets that must NOT leak to a team grantee.
"shared_token": "secret-public-token",
"key": "agentkey1234567890",
}
resp = _run(_patches("bob", repo, "viewer"), client, aid)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "team"
assert data["team_access"] == "viewer"
# Owner's public share token / API key are blanked for grantees.
assert data["shared_token"] == ""
assert data.get("key", "") == ""
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):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None
resp = _run(_patches("stranger", repo, None), client, aid)
assert resp.status_code == 404
repo.get_by_id.assert_not_called()
@pytest.mark.unit
class TestGetAgentResolvesNames:
"""GET /api/get_agent embeds owner-agnostic prompt/source names so a team
grantee sees the owner's prompt name + source names rather than a blank
prompt / "External KB" (the client can't resolve the owner's resources)."""
def test_team_member_payload_has_resolved_names(self, client):
aid = str(uuid.uuid4())
src = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None # not the owner
repo.get_by_id.return_value = {
"id": aid,
"name": "Shared",
"status": "published",
"prompt_id": str(uuid.uuid4()),
"source_id": src,
}
details = [{"id": src, "name": "Owner KB"}]
resp = _run(
_patches(
"bob", repo, "editor",
prompt_name="Owner Prompt", source_details=details,
),
client,
aid,
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "team"
assert data["prompt_name"] == "Owner Prompt"
assert data["source_details"] == details
def test_owner_payload_also_has_resolved_names(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = {
"id": aid,
"name": "Mine",
"status": "published",
"prompt_id": "default",
}
resp = _run(
_patches("alice", repo, None, prompt_name="Default", source_details=[]),
client,
aid,
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "user"
assert data["prompt_name"] == "Default"
assert data["source_details"] == []
def _update_patches(sub, repo, team_access, can_access_mock):
from docsgpt.api.user.resource_access import SponsorPlan
return [
patch("docsgpt.app.handle_auth", return_value={"sub": sub}),
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),
*_access_patches(sub, repo, team_access),
patch("docsgpt.api.user.agents.routes.can_access", can_access_mock),
# Sponsor bookkeeping queries the (mocked) connection; covered with a
# real database in tests/api/user/test_resource_sponsors.py.
patch("docsgpt.api.user.agents.routes.plan_sponsors", return_value=SponsorPlan()),
]
def _run_update(patches, client, agent_id, body):
for p in patches:
p.start()
try:
return client.put(f"/api/update_agent/{agent_id}", json=body)
finally:
for p in reversed(patches):
p.stop()
def _shared_agent_row(aid, owner_src, owner_prompt):
return {
"id": aid,
"name": "Shared",
"description": "desc",
"status": "draft",
"source_id": owner_src,
"extra_source_ids": [],
"prompt_id": owner_prompt,
"tools": [],
"chunks": 2,
"agent_type": "classic",
"image": "",
}
@pytest.mark.unit
class TestUpdateAgentEditorSaveGate:
"""PUT /api/update_agent: a team EDITOR may save an agent while keeping the
owner's existing source/prompt (which aren't independently shared with
them), but must not ATTACH a new source/prompt they can't access."""
def test_editor_keeps_owner_source_and_prompt(self, client):
aid, owner_src, owner_prompt = (
str(uuid.uuid4()),
str(uuid.uuid4()),
str(uuid.uuid4()),
)
repo = Mock()
repo.get_any.return_value = None # not the owner
repo.get_by_id.return_value = _shared_agent_row(aid, owner_src, owner_prompt)
repo.update_by_id.return_value = {"id": aid}
# Editor holds NO independent grant on the source/prompt themselves.
can_access = Mock(return_value=False)
resp = _run_update(
_update_patches("editor-bob", repo, "editor", can_access),
client,
aid,
{
"name": "Shared",
"description": "desc",
"source": owner_src,
"prompt_id": owner_prompt,
},
)
assert resp.status_code == 200
# Unchanged refs are exempt — the access check is never consulted.
can_access.assert_not_called()
repo.update_by_id.assert_called_once()
def test_editor_cannot_attach_new_unshared_source(self, client):
aid, owner_src, owner_prompt = (
str(uuid.uuid4()),
str(uuid.uuid4()),
str(uuid.uuid4()),
)
new_src = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None
repo.get_by_id.return_value = _shared_agent_row(aid, owner_src, owner_prompt)
repo.update_by_id.return_value = {"id": aid}
can_access = Mock(return_value=False)
resp = _run_update(
_update_patches("editor-bob", repo, "editor", can_access),
client,
aid,
{"name": "Shared", "description": "desc", "sources": [new_src]},
)
assert resp.status_code == 403
assert "Source not accessible" in json.loads(resp.data)["message"]
repo.update_by_id.assert_not_called()
def test_editor_cannot_attach_new_unshared_prompt(self, client):
aid, owner_src, owner_prompt = (
str(uuid.uuid4()),
str(uuid.uuid4()),
str(uuid.uuid4()),
)
new_prompt = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None
repo.get_by_id.return_value = _shared_agent_row(aid, owner_src, owner_prompt)
repo.update_by_id.return_value = {"id": aid}
can_access = Mock(return_value=False)
resp = _run_update(
_update_patches("editor-bob", repo, "editor", can_access),
client,
aid,
{
"name": "Shared",
"description": "desc",
"source": owner_src,
"prompt_id": new_prompt,
},
)
assert resp.status_code == 403
assert "Prompt not accessible" in json.loads(resp.data)["message"]
repo.update_by_id.assert_not_called()