feat: teams

This commit is contained in:
Alex committed 2026-06-16 09:21:21 +01:00
1 parent 49fb28acd3
commit d21f4758be
55 files changed
+5995 -169

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
"""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 application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
def _cm(value):
yield value
def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_details=None):
if source_details is None:
source_details = []
return [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=["user"]),
patch("application.api.user.agents.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.agents.routes.AgentsRepository", return_value=repo),
patch("application.api.user.agents.routes.team_access_for", return_value=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(
"application.api.user.agents.routes.resolve_prompt_name",
return_value=prompt_name,
),
patch(
"application.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
repo.get_by_id.assert_not_called()
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", "") == ""
# get_by_id only reached AFTER the team grant 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):
return [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=["user"]),
patch("application.api.user.agents.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.agents.routes.AgentsRepository", return_value=repo),
patch("application.api.user.agents.routes.team_access_for", return_value=team_access),
patch("application.api.user.agents.routes.can_access", can_access_mock),
]
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()
+397
View File
@@ -0,0 +1,397 @@
"""Endpoint authorization tests for the teams API.
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
correctness lives in tests/storage/db/repositories/test_teams.py.
"""
from __future__ import annotations
import json
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
@pytest.fixture
def client():
from application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
def _cm(value):
yield value
def _auth(sub="u1", roles=("user",), team_role=None):
"""Patch the chokepoint auth + the team_authz membership resolution.
``team_role`` is what TeamMembersRepository.role_for returns inside
team_authz (None = not a member).
"""
members = Mock()
members.role_for.return_value = team_role
return [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=list(roles)),
patch("application.api.user.team_authz.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.team_authz.TeamMembersRepository", return_value=members),
]
def _apply(patches):
for p in patches:
p.start()
def _stop(patches):
for p in reversed(patches):
p.stop()
@pytest.mark.unit
class TestTeamCreation:
def test_unauthenticated_401(self, client):
with patch("application.app.handle_auth", return_value=None):
resp = client.post("/api/teams", json={"name": "Acme"})
assert resp.status_code == 401
def test_self_serve_create_returns_201(self, client):
teams_repo = Mock()
teams_repo.slug_exists.return_value = False
teams_repo.create.return_value = {"id": "t1", "name": "Acme", "slug": "acme"}
members_repo = Mock()
patches = _auth(sub="alice") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.post("/api/teams", json={"name": "Acme"})
finally:
_stop(patches)
assert resp.status_code == 201
# Creator is added as team_admin in the same flow.
args, kwargs = members_repo.add_member.call_args
assert kwargs.get("role") == "team_admin" or "team_admin" in args
def test_missing_name_400(self, client):
patches = _auth(sub="alice")
_apply(patches)
try:
resp = client.post("/api/teams", json={})
finally:
_stop(patches)
assert resp.status_code == 400
@pytest.mark.unit
class TestTeamAccessControl:
def test_detail_requires_membership(self, client):
patches = _auth(sub="stranger", team_role=None)
_apply(patches)
try:
resp = client.get("/api/teams/team-1")
finally:
_stop(patches)
assert resp.status_code == 403
def test_member_can_read_detail(self, client):
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "name": "Acme", "owner_id": "alice"}
members_repo = Mock()
members_repo.list_members.return_value = []
members_repo.role_for.return_value = "team_member"
patches = _auth(sub="bob", team_role="team_member") + [
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.get("/api/teams/team-1")
finally:
_stop(patches)
assert resp.status_code == 200
def test_team_id_taken_from_path_not_body(self, client):
# Caller is NOT a member of team-1; a forged team_id in the body must be
# ignored — the decorator reads team_id from the URL path only.
patches = _auth(sub="stranger", team_role=None)
_apply(patches)
try:
resp = client.get("/api/teams/team-1", json={"team_id": "team-i-own"})
finally:
_stop(patches)
assert resp.status_code == 403
def test_add_member_requires_team_admin(self, client):
patches = _auth(sub="bob", team_role="team_member")
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members", json={"user_id": "x", "role": "team_member"}
)
finally:
_stop(patches)
assert resp.status_code == 403
def test_team_admin_can_add_member(self, client):
members_repo = Mock()
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members", json={"user_id": "carol", "role": "team_member"}
)
finally:
_stop(patches)
assert resp.status_code == 200
members_repo.set_manual_role.assert_called_once()
def test_add_member_by_email_resolves_to_sub(self, client):
members_repo = Mock()
users_repo = Mock()
users_repo.find_by_email.return_value = {"user_id": "resolved-sub"}
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.UsersRepository",
return_value=users_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members",
json={"email": "bob@example.com", "role": "team_member"},
)
finally:
_stop(patches)
assert resp.status_code == 200
args, kwargs = members_repo.set_manual_role.call_args
assert "resolved-sub" in args # resolved sub used, not the email
def test_add_member_email_not_found(self, client):
users_repo = Mock()
users_repo.find_by_email.return_value = None
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.UsersRepository",
return_value=users_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members",
json={"email": "ghost@example.com", "role": "team_member"},
)
finally:
_stop(patches)
assert resp.status_code == 404
def test_delete_team_non_owner_forbidden(self, client):
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "owner_id": "alice"}
patches = _auth(sub="bob", roles=("user",), team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
]
_apply(patches)
try:
resp = client.delete("/api/teams/team-1")
finally:
_stop(patches)
# team_admin is not the owner — only the owner (or a global admin) deletes.
assert resp.status_code == 403
teams_repo.delete.assert_not_called()
@pytest.mark.unit
class TestSharingAuthz:
def test_share_requires_ownership(self, client):
patches = _auth(sub="bob", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=False),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "11111111-1111-1111-1111-111111111111",
},
)
finally:
_stop(patches)
assert resp.status_code == 403
def test_owner_can_share(self, client):
grants_repo = Mock()
grants_repo.grant.return_value = {"id": "g1", "access_level": "viewer"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"access_level": "editor",
},
)
finally:
_stop(patches)
assert resp.status_code == 201
_, kwargs = grants_repo.grant.call_args
assert kwargs.get("access_level") == "editor"
def test_invalid_resource_type_rejected(self, client):
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={"resource_type": "secret_thing", "resource_id": "a1"},
)
finally:
_stop(patches)
assert resp.status_code == 400
def test_share_with_member_validates_membership(self, client):
# target_user_id must be a member of the team.
members_repo = Mock()
members_repo.is_member.return_value = False
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"target_user_id": "stranger",
},
)
finally:
_stop(patches)
assert resp.status_code == 400
def test_share_with_valid_member(self, client):
members_repo = Mock()
members_repo.is_member.return_value = True
grants_repo = Mock()
grants_repo.grant.return_value = {"id": "g1", "target_user_id": "bob"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"target_user_id": "bob",
},
)
finally:
_stop(patches)
assert resp.status_code == 201
_, kwargs = grants_repo.grant.call_args
assert kwargs.get("target_user_id") == "bob"
def test_non_uuid_resource_id_rejected(self, client):
# Team grants are UUID-only (post-cutover); a legacy/non-UUID id must be
# rejected cleanly, not cast-and-poison the txn into a generic error.
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={"resource_type": "agent", "resource_id": "legacy-mongo-id"},
)
finally:
_stop(patches)
assert resp.status_code == 400
@pytest.mark.unit
class TestAdminOversight:
def test_non_admin_forbidden(self, client):
patches = _auth(sub="bob", roles=("user",))
_apply(patches)
try:
resp = client.get("/api/admin/teams")
finally:
_stop(patches)
assert resp.status_code == 403
def test_global_admin_lists_all(self, client):
teams_repo = Mock()
teams_repo.list_all.return_value = [{"id": "t1", "member_count": 3}]
patches = _auth(sub="root", roles=("admin", "user")) + [
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
]
_apply(patches)
try:
resp = client.get("/api/admin/teams")
finally:
_stop(patches)
assert resp.status_code == 200
assert json.loads(resp.data)["teams"][0]["member_count"] == 3
+12 -6
View File
@@ -210,7 +210,9 @@ class TestAddChunk:
response = AddChunk().post()
assert response.status_code == 400
def test_returns_404_source_not_found(self, app, pg_conn):
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".
from application.api.user.sources.chunks import AddChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -223,7 +225,7 @@ class TestAddChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
assert response.status_code == 404
assert response.status_code == 403
def test_adds_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import AddChunk
@@ -282,7 +284,9 @@ class TestDeleteChunk:
response = DeleteChunk().delete()
assert response.status_code == 401
def test_returns_404_source_not_found(self, app, pg_conn):
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".
from application.api.user.sources.chunks import DeleteChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -292,7 +296,7 @@ class TestDeleteChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteChunk().delete()
assert response.status_code == 404
assert response.status_code == 403
def test_deletes_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import DeleteChunk
@@ -359,7 +363,9 @@ class TestUpdateChunk:
response = UpdateChunk().put()
assert response.status_code == 400
def test_returns_404_source_not_found(self, app, pg_conn):
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".
from application.api.user.sources.chunks import UpdateChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -372,7 +378,7 @@ class TestUpdateChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
assert response.status_code == 404
assert response.status_code == 403
def test_returns_404_chunk_not_found(self, app, pg_conn):
from application.api.user.sources.chunks import UpdateChunk
+60 -4
View File
@@ -512,7 +512,9 @@ class TestSyncSource:
response = SyncSource().post()
assert response.status_code == 400
def test_returns_404_missing_source(self, app, pg_conn):
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".
from application.api.user.sources.routes import SyncSource
with _patch_db(pg_conn), app.test_request_context(
@@ -523,7 +525,7 @@ class TestSyncSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = SyncSource().post()
assert response.status_code == 404
assert response.status_code == 403
def test_returns_400_for_connector_type(self, app, pg_conn):
from application.api.user.sources.routes import SyncSource
@@ -659,7 +661,9 @@ class TestReingestSource:
response = ReingestSource().post()
assert response.status_code == 400
def test_returns_404_missing_source(self, app, pg_conn):
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".
from application.api.user.sources.routes import ReingestSource
with _patch_db(pg_conn), app.test_request_context(
@@ -670,7 +674,7 @@ class TestReingestSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = ReingestSource().post()
assert response.status_code == 404
assert response.status_code == 403
def test_triggers_reingest_task(self, app, pg_conn):
from application.api.user.sources.routes import ReingestSource
@@ -701,6 +705,58 @@ class TestReingestSource:
f"reingest-source:{user}:{src['id']}"
)
def test_team_editor_reingests_as_owner(self, app, pg_conn):
"""A team editor (not the owner) can reingest a shared source; the
task dispatches AS the real owner so the owner-scoped pipeline and
the owner-agnostic vector partition stay consistent.
"""
import uuid
from application.api.user.sources.routes import ReingestSource
from application.storage.db.repositories.team_members import (
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
owner = "alice-reingest"
editor = "bob-reingest"
src = _seed_source(pg_conn, owner, name="shared-src", type="file")
sid = str(src["id"])
team = TeamsRepository(pg_conn).create(
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
)
TeamMembersRepository(pg_conn).add_member(
team["id"], editor, role="team_member"
)
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", sid, owner_id=owner, granted_by=owner,
access_level="editor",
)
fake_task = MagicMock(id="reingest-task-editor")
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reingest_source_task.delay",
return_value=fake_task,
) as mock_delay, app.test_request_context(
"/api/sources/reingest",
method="POST",
json={"source_id": sid},
):
from flask import request
request.decoded_token = {"sub": editor}
response = ReingestSource().post()
assert response.status_code == 200
assert mock_delay.call_args.kwargs["source_id"] == sid
# Dispatched AS the owner, not the editor caller.
assert mock_delay.call_args.kwargs["user"] == owner
assert mock_delay.call_args.kwargs["idempotency_key"] == (
f"reingest-source:{owner}:{sid}"
)
def test_clears_stalled_ingest_progress_row(self, app, pg_conn):
"""Reingest drops the stale chunk-progress row so the sources
list stops deriving a 'failed' ingest status for the source.