Files
roboco/tests/unit/gateway/test_content_actions_ownership.py
T
ce89a04d26 [a1f52d13] Rebuild evidence-assembly timeout fix on current main (#796)
* [a1f52d13] feat(gateway): re-apply evidence-assembly timeout fix on current main

Rebuilds the bounded-timeout fix for claim_review/evidence()/roboco_git_diff
that PR #765 carried as contaminated pre-Board-Programs file copies. The
rebase restored the current-main baseline; this commit re-applies ONLY the
validated timeout-fix hunks on top of that baseline:

- config.py: evidence_assembly_timeout_seconds (90.0s) and
  conventions_validator_timeout_seconds (45s) Settings fields
- envelope.py: Envelope.gateway_timeout classmethod for structured
  timeout errors naming the stalled component
- git.py: GitService.diff_and_files (single resolution, concurrent
  diff+--name-only subprocesses), settings-backed conventions-validator
  timeout, and changed_files passthrough to conventions_check_for_task
- qa.py: claim_review bounded-timeout wrapper around
  _build_qa_claim_evidence, split into _qa_git_and_conventions and
  _qa_db_reads concurrent segments (pass_review signature and gate
  untouched)
- content_actions.py: evidence() bounded wrapper with _evidence_git_and_fetch
  and _evidence_db_reads concurrent split (all other methods untouched)
- git routes: GET /diff bounded asyncio.wait_for guard with 504 on trip

The pre-existing methods (sync_env_branch, open_sync_pr, pass_review with
criteria_verified gate, all propose_*/request_render methods) are preserved
and untouched. The test files already exist on current main.

* [a1f52d13] fix(git-routes): wrap workspace resolution in bounded timeout for GET /diff

The bounded asyncio.wait_for must cover the workspace resolution step too,
not just the diff+stat subprocesses — the integration test makes
get_workspace slow (not _run_git), so the timeout guard needs to enclose
the entire resolve-and-diff path to return a structured 504 on trip.

* [a1f52d13] test: update mock setups from git.diff/list_changed_files to diff_and_files

Tests that exercise claim_review and evidence() paths need their git
mocks to return a tuple from diff_and_files instead of separate diff
and list_changed_files return values, matching the new combined accessor
introduced by the evidence-assembly timeout fix.

* [a1f52d13] test: configure diff_and_files mock in lifecycle parity test _make_deps

The spec-parity test test_claim_review_matches_spec[qa-awaiting_qa] failed
with ValueError at qa.py:330 because _make_deps set git=AsyncMock() without
configuring diff_and_files.return_value, so the await resolved to a MagicMock
instead of a (diff_summary, files_changed) 2-tuple. Adds the same
return_value = ("", []) configuration that commit 014a0d03 applied to the
other 4 test files.

* [a1f52d13] docs(evidence-assembly-timeout): correct GET /diff guard scope and _qa_git_and_conventions timing field

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
2026-08-02 03:22:25 +00:00

524 lines
17 KiB
Python

"""Gate Set D: content-tool ownership guards in ContentActions.
When a caller passes a ``task_id`` to commit / dm / note / evidence,
the gateway must verify ``task.assigned_to == caller_agent_id`` before
allowing the side effect.
Pre-gateway equivalent: agents could only act on tasks they owned because
the MCP handlers resolved task from session context. With the gateway
exposing the task_id parameter, the ownership check must be explicit.
Exception: ``dm`` without a task_id (channel-only / off-task
A2A) are exempt — used for channel announcements.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
if "task" in overrides:
task = overrides["task"]
else:
task = AsyncMock()
task.get_active_task_for_agent.return_value = None
# commit() now checks caller role; default to developer.
task.agent_for.return_value = MagicMock(role="developer")
if "git" in overrides:
git = overrides["git"]
else:
git = AsyncMock()
git.commit.return_value = {"sha": "abc12345"}
git.diff_and_files.return_value = ("", [])
a2a = overrides.get("a2a", AsyncMock())
journal = overrides.get("journal", AsyncMock())
workspace = overrides.get("workspace", AsyncMock())
notifications = overrides.get("notifications", AsyncMock())
if "evidence_repo" in overrides:
evidence_repo = overrides["evidence_repo"]
else:
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
return ContentActionsDeps(
task=task,
git=git,
a2a=a2a,
journal=journal,
workspace=workspace,
notifications=notifications,
evidence_repo=evidence_repo,
)
# ---------------------------------------------------------------------------
# commit — uses get_active_task_for_agent which is already a self-scoped query.
# But if a caller had explicit task_id semantics, we'd guard them. The current
# commit verb uses active task; tests below verify it cannot reach a task that
# isn't theirs (active task query handles this naturally).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_active_task_returned_must_be_caller_owned() -> None:
"""get_active_task_for_agent already self-scopes; verify call signature."""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
active_claimant_id=agent_id,
branch_name="feature/backend/abc",
)
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = task_obj
git_svc = AsyncMock()
git_svc.commit.return_value = {"sha": "deadbeef1234"}
deps = _make_deps(task=task_svc, git=git_svc)
ca = ContentActions(deps)
env = await ca.commit(
agent_id=agent_id,
message="feat(api): add /healthz endpoint",
)
assert env.error is None
task_svc.get_active_task_for_agent.assert_awaited_once_with(agent_id)
# ---------------------------------------------------------------------------
# note — explicit task_id ownership
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_note_with_task_id_blocks_when_not_assignee() -> None:
"""note(task_id=X) where X is owned by someone else is rejected."""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(id=task_id, status="in_progress", assigned_to=other_id)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="I am putting a note on someone else's task",
scope="reflect",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert str(task_id) in body["message"] or "assignee" in body["message"]
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_note_with_task_id_allows_when_assignee() -> None:
"""note(task_id=X) where X is owned by caller succeeds."""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
active_claimant_id=agent_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
task_svc.get_active_task_for_agent.return_value = None
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
# Uses scope='note' (no structured-field requirement) — the test
# exercises the ownership gate, not the journal-shape gate (which is
# covered separately in test_content_actions.py).
env = await ca.note(
agent_id=agent_id,
text="Working on my own task",
scope="note",
task_id=task_id,
)
assert env.error is None
journal_svc.write_entry.assert_awaited_once()
@pytest.mark.asyncio
async def test_note_without_task_id_skips_ownership_check() -> None:
"""note without task_id is fine (might be a general entry)."""
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="A general note with enough length",
scope="note",
)
assert env.error is None
journal_svc.write_entry.assert_awaited_once()
# ---------------------------------------------------------------------------
# say — explicit task_id ownership (only when task_id provided)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# dm — explicit task_id ownership
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dm_with_task_id_blocks_when_not_assignee() -> None:
"""dm(task_id=X) where X is owned by someone else is rejected."""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(id=task_id, status="in_progress", assigned_to=other_id)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
a2a_svc = AsyncMock()
deps = _make_deps(task=task_svc, a2a=a2a_svc)
ca = ContentActions(deps)
env = await ca.dm(
agent_id=agent_id,
recipient="be-qa-1",
text="DM about someone else's task",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized"
a2a_svc.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_dm_with_task_id_owned_by_caller_succeeds() -> None:
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
active_claimant_id=agent_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
a2a_svc = AsyncMock()
deps = _make_deps(task=task_svc, a2a=a2a_svc)
ca = ContentActions(deps)
env = await ca.dm(
agent_id=agent_id,
recipient="be-qa-1",
text="DM about my own task",
task_id=task_id,
)
assert env.error is None
a2a_svc.send.assert_awaited_once()
# ---------------------------------------------------------------------------
# evidence — explicit task_id ownership
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_evidence_blocks_when_not_assignee() -> None:
"""evidence(task_id=X) where X is owned by someone else is rejected.
Exception: QA/documenter agents legitimately review tasks they don't
own. We allow the read when assigned_to is None (post-handoff state)
or matches the caller. The strict gate is for posting/writing only.
For this test we use a task with another developer as assignee — the
inspect call is denied because there's no role-based exemption logic
yet at the gateway layer for content-tools.
"""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="in_progress",
assigned_to=other_id,
branch_name="feature/backend/abc",
work_session_id=uuid4(),
commits=["sha1"],
pr_number=8,
pr_url="x",
dev_notes="",
acceptance_criteria_status=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
git_svc = AsyncMock()
git_svc.diff_and_files.return_value = ("", [])
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "not_authorized"
workspace_svc.fetch_branch_for_inspection.assert_not_awaited()
git_svc.diff_and_files.assert_not_awaited()
# ---------------------------------------------------------------------------
# Board co-review exemption (cluster C5): a board role may record its review
# note/say on a board/coordination task held by the OTHER board member.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_board_role_may_note_coordination_task_held_by_other_board() -> None:
"""A board/coordination task (project_id=None, product_id set) is reviewed
by BOTH board members; the non-assignee board reviewer may still note it."""
agent_id = uuid4()
other_board_id = uuid4()
task_id = uuid4()
coord_task = MagicMock(
id=task_id,
status="pending",
assigned_to=other_board_id,
project_id=None,
product_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = coord_task
task_svc.get_active_task_for_agent.return_value = None
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
# _make_deps defaults agent_for to a developer; override AFTER so the
# board co-review exemption sees a board role.
task_svc.agent_for.return_value = MagicMock(role="head_marketing")
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="UX + positioning review of the board task",
scope="note",
task_id=task_id,
)
assert env.error is None
journal_svc.write_entry.assert_awaited_once()
@pytest.mark.asyncio
async def test_board_role_blocked_on_project_task_held_by_other() -> None:
"""The exemption is narrow: a board role still cannot post to a normal
project-backed task assigned to someone else."""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
project_task = MagicMock(
id=task_id,
status="in_progress",
assigned_to=other_id,
project_id=uuid4(),
product_id=None,
)
task_svc = AsyncMock()
task_svc.get.return_value = project_task
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
# Board role, but a project-backed task — exemption must NOT apply.
task_svc.agent_for.return_value = MagicMock(role="product_owner")
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="trying to note a code task I don't own",
scope="note",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized"
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_board_role_blocked_on_coordination_task_held_by_other() -> None:
"""The exemption is board-only: a developer cannot piggyback on it."""
agent_id = uuid4()
other_id = uuid4()
task_id = uuid4()
coord_task = MagicMock(
id=task_id,
status="pending",
assigned_to=other_id,
project_id=None,
product_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = coord_task
task_svc.agent_for.return_value = MagicMock(role="developer")
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="dev trying to note a coordination task",
scope="note",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized"
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_evidence_unassigned_task_allows_inspection() -> None:
"""A task with assigned_to=None (between handoffs) may be inspected.
This handles the QA-after-dev-submit case where assigned_to is briefly
None until reassigned. Strict ownership only applies to write-side.
"""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
branch_name="feature/backend/abc",
work_session_id=uuid4(),
commits=["sha1"],
pr_number=8,
pr_url="x",
dev_notes="",
acceptance_criteria_status=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
git_svc = AsyncMock()
git_svc.diff_and_files.return_value = ("diff content", [])
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
assert env.error is None
@pytest.mark.asyncio
async def test_evidence_allows_dependency_inspection() -> None:
"""A caller whose own assigned task depends on the target may read it.
A frontend cell waiting on a UX design task must be able to inspect the
dependency it is blocked on, even though another agent owns it.
"""
agent_id = uuid4()
other_id = uuid4()
dep_task_id = uuid4()
target = MagicMock(
id=dep_task_id,
status="in_progress",
assigned_to=other_id,
branch_name="feature/ux_ui/abc",
work_session_id=uuid4(),
commits=[],
pr_number=None,
pr_url="",
dev_notes="",
acceptance_criteria_status=[],
)
callers_task = MagicMock(id=uuid4(), dependency_ids=[dep_task_id])
task_svc = AsyncMock()
task_svc.get.return_value = target
task_svc.list_assigned_for_agent.return_value = [callers_task]
git_svc = AsyncMock()
git_svc.diff_and_files.return_value = ("", [])
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=dep_task_id)
assert env.error is None
task_svc.list_assigned_for_agent.assert_awaited()
# ---------------------------------------------------------------------------
# Reaped/handoff window: assigned_to persists, active_claimant_id is cleared
# on release. A reaped agent must not keep posting to its former task.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_note_reaped_assignee_cannot_post_to_former_task() -> None:
"""A reaped agent (assigned_to=caller, active_claimant_id=None) is rejected
on note(task_id=X) — its claim was released, so it must not journal on the
task it no longer actively holds."""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id, # persists across the reap
active_claimant_id=None, # cleared on release — the reaped window
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="Posting after my claim was reaped",
scope="note",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "not_authorized", body
assert "active claim" in body["message"], body
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_note_active_owner_can_still_post() -> None:
"""No-regression: an active owner (assigned_to=caller AND
active_claimant_id=caller) still posts. The reaped-window rejection must
not weaken the legitimate active-owner path."""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
active_claimant_id=agent_id, # active claim — the normal case
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
task_svc.get_active_task_for_agent.return_value = None
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="Working on my actively-claimed task",
scope="note",
task_id=task_id,
)
assert env.error is None, env.as_dict()
journal_svc.write_entry.assert_awaited_once()