arc53--docsgpt
fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
319 行
15 KiB
Python
319 行
15 KiB
Python
"""Tests for the teams repositories against a real Postgres schema.
|
|
|
|
Covers TeamsRepository, TeamMembersRepository, TeamResourceGrantsRepository,
|
|
TeamScopeRepository, and the dangling-grant cleanup trigger from migration 0021.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from application.storage.db.repositories.agents import AgentsRepository
|
|
from application.storage.db.repositories.team_members import TeamMembersRepository
|
|
from application.storage.db.repositories.team_resource_grants import (
|
|
TeamResourceGrantsRepository,
|
|
)
|
|
from application.storage.db.repositories.team_scope import TeamScopeRepository
|
|
from application.storage.db.repositories.teams import TeamsRepository
|
|
from application.storage.db.repositories.users import UsersRepository
|
|
|
|
|
|
def _new_team(conn, name="Acme", slug=None, owner="alice"):
|
|
slug = slug or f"acme-{uuid.uuid4().hex[:8]}"
|
|
return TeamsRepository(conn).create(name=name, slug=slug, owner_id=owner)
|
|
|
|
|
|
class TestTeamsRepository:
|
|
def test_create_get_roundtrip(self, pg_conn):
|
|
repo = TeamsRepository(pg_conn)
|
|
team = _new_team(pg_conn, slug="acme")
|
|
assert team["name"] == "Acme"
|
|
assert team["owner_id"] == "alice"
|
|
fetched = repo.get(team["id"])
|
|
assert fetched["id"] == team["id"]
|
|
assert repo.get_by_slug("acme")["id"] == team["id"]
|
|
assert repo.slug_exists("acme") is True
|
|
assert repo.slug_exists("nope") is False
|
|
|
|
def test_slug_is_case_insensitive(self, pg_conn):
|
|
repo = TeamsRepository(pg_conn)
|
|
_new_team(pg_conn, slug="Acme")
|
|
# CITEXT slug — lookups are case-insensitive.
|
|
assert repo.get_by_slug("acme") is not None
|
|
|
|
def test_list_for_user_annotates_role(self, pg_conn):
|
|
teams = TeamsRepository(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
t1 = _new_team(pg_conn, name="One", owner="alice")
|
|
t2 = _new_team(pg_conn, name="Two", owner="bob")
|
|
members.add_member(t1["id"], "alice", role="team_admin")
|
|
members.add_member(t2["id"], "alice", role="team_member")
|
|
rows = {r["name"]: r["member_role"] for r in teams.list_for_user("alice")}
|
|
assert rows == {"One": "team_admin", "Two": "team_member"}
|
|
|
|
def test_list_for_user_empty(self, pg_conn):
|
|
assert TeamsRepository(pg_conn).list_for_user("nobody") == []
|
|
|
|
def test_update_and_reassign_owner(self, pg_conn):
|
|
repo = TeamsRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
assert repo.update(team["id"], {"name": "Renamed", "bogus": "x"}) is True
|
|
assert repo.get(team["id"])["name"] == "Renamed"
|
|
assert repo.reassign_owner(team["id"], "carol") is True
|
|
assert repo.get(team["id"])["owner_id"] == "carol"
|
|
|
|
def test_delete_cascades_members(self, pg_conn):
|
|
teams = TeamsRepository(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
members.add_member(team["id"], "alice", role="team_admin")
|
|
# Keep the side-effecting call off the assert line so it always runs,
|
|
# even under `python -O` (asserts stripped).
|
|
deleted = teams.delete(team["id"])
|
|
assert deleted is True
|
|
assert members.list_members(team["id"]) == []
|
|
|
|
|
|
class TestTeamMembers:
|
|
def test_role_for_strongest_wins(self, pg_conn):
|
|
members = TeamMembersRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
members.add_member(team["id"], "alice", role="team_member")
|
|
members.add_member(team["id"], "alice", role="team_admin")
|
|
assert members.role_for("alice", team["id"]) == "team_admin"
|
|
assert members.is_member("alice", team["id"]) is True
|
|
|
|
def test_role_for_non_member_is_none(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
assert TeamMembersRepository(pg_conn).role_for("ghost", team["id"]) is None
|
|
|
|
def test_list_team_ids_for(self, pg_conn):
|
|
members = TeamMembersRepository(pg_conn)
|
|
t1 = _new_team(pg_conn, name="One")
|
|
t2 = _new_team(pg_conn, name="Two")
|
|
members.add_member(t1["id"], "alice", role="team_member")
|
|
members.add_member(t2["id"], "alice", role="team_member")
|
|
assert set(members.list_team_ids_for("alice")) == {t1["id"], t2["id"]}
|
|
|
|
def test_set_manual_role_demotes(self, pg_conn):
|
|
members = TeamMembersRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
members.add_member(team["id"], "alice", role="team_admin")
|
|
members.set_manual_role(team["id"], "alice", "team_member")
|
|
assert members.role_for("alice", team["id"]) == "team_member"
|
|
# Exactly one manual row remains.
|
|
rows = [m for m in members.list_members(team["id"]) if m["source"] == "manual"]
|
|
assert len(rows) == 1 and rows[0]["role"] == "team_member"
|
|
|
|
def test_count_admins_and_last_admin_guard_data(self, pg_conn):
|
|
members = TeamMembersRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
members.add_member(team["id"], "alice", role="team_admin")
|
|
members.add_member(team["id"], "bob", role="team_admin")
|
|
assert members.count_admins(team["id"]) == 2
|
|
members.remove_member(team["id"], "bob")
|
|
assert members.count_admins(team["id"]) == 1
|
|
|
|
def test_remove_member_scoped_by_source(self, pg_conn):
|
|
members = TeamMembersRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
members.add_member(team["id"], "alice", role="team_member", source="manual")
|
|
members.add_member(team["id"], "alice", role="team_member", source="oidc_group")
|
|
# Removing only the oidc grant keeps the manual one (IdP sync can't wipe manual).
|
|
members.remove_member(team["id"], "alice", source="oidc_group")
|
|
assert members.is_member("alice", team["id"]) is True
|
|
members.remove_member(team["id"], "alice")
|
|
assert members.is_member("alice", team["id"]) is False
|
|
|
|
|
|
class TestResourceGrants:
|
|
def test_grant_then_list(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
rid = str(uuid.uuid4())
|
|
row = grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
|
|
assert row["access_level"] == "viewer"
|
|
listed = grants.list_for_team(team["id"], "agent")
|
|
assert len(listed) == 1 and listed[0]["resource_id"] == rid
|
|
|
|
def test_re_share_updates_access_level(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
|
|
grants.grant(
|
|
team["id"], "agent", rid, owner_id="alice", granted_by="alice", access_level="editor"
|
|
)
|
|
assert grants.get(team["id"], "agent", rid)["access_level"] == "editor"
|
|
# ON CONFLICT — still exactly one row.
|
|
assert len(grants.list_for_team(team["id"], "agent")) == 1
|
|
|
|
def test_revoke(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
|
|
assert grants.revoke(team["id"], "agent", rid) is True
|
|
assert grants.get(team["id"], "agent", rid) is None
|
|
|
|
def test_list_for_resource_includes_team_name(self, pg_conn):
|
|
team = _new_team(pg_conn, name="Acme")
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "prompt", rid, owner_id="alice", granted_by="alice")
|
|
rows = grants.list_for_resource("prompt", rid)
|
|
assert rows[0]["team_name"] == "Acme"
|
|
|
|
|
|
class TestTeamScope:
|
|
def test_visible_ids_via_membership(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
scope = TeamScopeRepository(pg_conn)
|
|
members.add_member(team["id"], "bob", role="team_member")
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
|
|
assert scope.visible_resource_ids("bob", "agent") == {rid}
|
|
# A non-member sees nothing.
|
|
assert scope.visible_resource_ids("ghost", "agent") == set()
|
|
# Wrong resource type is filtered out.
|
|
assert scope.visible_resource_ids("bob", "source") == set()
|
|
|
|
def test_revoking_membership_drops_visibility(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
scope = TeamScopeRepository(pg_conn)
|
|
members.add_member(team["id"], "bob", role="team_member")
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
|
|
members.remove_member(team["id"], "bob")
|
|
# Live JOIN — visibility gone immediately, grant row untouched.
|
|
assert scope.visible_resource_ids("bob", "agent") == set()
|
|
assert grants.get(team["id"], "agent", rid) is not None
|
|
|
|
def test_effective_access_editor_wins_across_teams(self, pg_conn):
|
|
t1 = _new_team(pg_conn, name="One")
|
|
t2 = _new_team(pg_conn, name="Two")
|
|
members = TeamMembersRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
scope = TeamScopeRepository(pg_conn)
|
|
members.add_member(t1["id"], "bob", role="team_member")
|
|
members.add_member(t2["id"], "bob", role="team_member")
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(t1["id"], "tool", rid, owner_id="alice", granted_by="alice") # viewer
|
|
grants.grant(
|
|
t2["id"], "tool", rid, owner_id="alice", granted_by="alice", access_level="editor"
|
|
)
|
|
assert scope.effective_access("bob", "tool", rid) == "editor"
|
|
assert scope.can_write("bob", "tool", rid) is True
|
|
assert scope.can_read("bob", "tool", rid) is True
|
|
# Non-member: no access.
|
|
assert scope.effective_access("ghost", "tool", rid) is None
|
|
assert scope.can_write("ghost", "tool", rid) is False
|
|
|
|
|
|
class TestPerMemberSharing:
|
|
def test_member_grant_visible_only_to_target(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
scope = TeamScopeRepository(pg_conn)
|
|
members.add_member(team["id"], "bob", role="team_member")
|
|
members.add_member(team["id"], "carol", role="team_member")
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(
|
|
team["id"], "agent", rid, owner_id="alice", granted_by="alice", target_user_id="bob"
|
|
)
|
|
# Only the targeted member sees a per-member grant.
|
|
assert scope.visible_resource_ids("bob", "agent") == {rid}
|
|
assert scope.visible_resource_ids("carol", "agent") == set()
|
|
assert scope.effective_access("bob", "agent", rid) == "viewer"
|
|
assert scope.effective_access("carol", "agent", rid) is None
|
|
|
|
def test_whole_team_and_member_grants_coexist(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
scope = TeamScopeRepository(pg_conn)
|
|
members.add_member(team["id"], "bob", role="team_member")
|
|
members.add_member(team["id"], "carol", role="team_member")
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice") # whole team
|
|
grants.grant(
|
|
team["id"], "agent", rid, owner_id="alice", granted_by="alice",
|
|
target_user_id="bob", access_level="editor",
|
|
)
|
|
# Distinct rows (functional dedup over COALESCE(target,'')).
|
|
assert len(grants.list_for_team(team["id"], "agent")) == 2
|
|
# bob gets the strongest across whole-team + his member grant; carol only viewer.
|
|
assert scope.effective_access("bob", "agent", rid) == "editor"
|
|
assert scope.effective_access("carol", "agent", rid) == "viewer"
|
|
|
|
def test_revoke_member_grant_keeps_whole_team(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
members.add_member(team["id"], "bob", role="team_member")
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
|
|
grants.grant(
|
|
team["id"], "agent", rid, owner_id="alice", granted_by="alice", target_user_id="bob"
|
|
)
|
|
assert grants.revoke(team["id"], "agent", rid, target_user_id="bob") is True
|
|
assert grants.get(team["id"], "agent", rid, target_user_id="bob") is None
|
|
# Whole-team grant untouched.
|
|
assert grants.get(team["id"], "agent", rid) is not None
|
|
|
|
def test_member_grant_needs_membership_for_visibility(self, pg_conn):
|
|
# A grant targeting a non-member is inert — the live JOIN requires the
|
|
# viewer to be a team member.
|
|
team = _new_team(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
scope = TeamScopeRepository(pg_conn)
|
|
rid = str(uuid.uuid4())
|
|
grants.grant(
|
|
team["id"], "agent", rid, owner_id="alice", granted_by="alice", target_user_id="ghost"
|
|
)
|
|
assert scope.visible_resource_ids("ghost", "agent") == set()
|
|
|
|
|
|
class TestEmailLookup:
|
|
def test_upsert_stores_and_find_by_email_case_insensitive(self, pg_conn):
|
|
users = UsersRepository(pg_conn)
|
|
users.upsert("sub-1", email="Alice@Example.com")
|
|
row = users.find_by_email("alice@example.com")
|
|
assert row is not None and row["user_id"] == "sub-1"
|
|
assert users.find_by_email("nobody@example.com") is None
|
|
assert users.find_by_email("") is None
|
|
|
|
def test_upsert_without_email_preserves_existing(self, pg_conn):
|
|
users = UsersRepository(pg_conn)
|
|
users.upsert("sub-2", email="bob@example.com")
|
|
users.upsert("sub-2") # a non-OIDC upsert must not wipe the stored email
|
|
assert users.find_by_email("bob@example.com")["user_id"] == "sub-2"
|
|
|
|
def test_list_members_includes_email(self, pg_conn):
|
|
users = UsersRepository(pg_conn)
|
|
members = TeamMembersRepository(pg_conn)
|
|
team = _new_team(pg_conn)
|
|
users.upsert("carol", email="carol@team.com")
|
|
members.add_member(team["id"], "carol", role="team_member")
|
|
row = next(m for m in members.list_members(team["id"]) if m["user_id"] == "carol")
|
|
assert row["email"] == "carol@team.com"
|
|
|
|
|
|
class TestCleanupTrigger:
|
|
def test_deleting_agent_scrubs_its_grants(self, pg_conn):
|
|
team = _new_team(pg_conn)
|
|
agents = AgentsRepository(pg_conn)
|
|
grants = TeamResourceGrantsRepository(pg_conn)
|
|
agent = agents.create("alice", "My Agent", "published")
|
|
agent_id = str(agent["id"])
|
|
grants.grant(team["id"], "agent", agent_id, owner_id="alice", granted_by="alice")
|
|
assert grants.get(team["id"], "agent", agent_id) is not None
|
|
# AFTER DELETE trigger (migration 0021) removes the dangling grant.
|
|
agents.delete(agent_id, "alice")
|
|
assert grants.get(team["id"], "agent", agent_id) is None
|