mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Pre-gateway PMs/Board sent formal notifications (require ack); gateway had say/dm only. Now PMs and Board can issue ack-required notifications via NotificationService through the standard envelope path.
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""ContentActions.say must enforce channel write-access via send_message.
|
|
|
|
The gateway path (post_to_channel -> send_message) historically called
|
|
send_message WITHOUT agent_slug, which bypassed validate_channel_access.
|
|
Pre-gateway returned a friendly ChannelAccessDeniedError listing the
|
|
agent's writable channels. These tests pin the restored behaviour:
|
|
|
|
1. say() converts ChannelAccessDeniedError into a not_authorized Envelope
|
|
that names the offending channel and includes a remediation hint
|
|
listing the writable channels for the agent's role.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.enforcement.channel_access import ChannelAccessDeniedError
|
|
from roboco.services.gateway.content_actions import (
|
|
ContentActions,
|
|
ContentActionsDeps,
|
|
)
|
|
|
|
|
|
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
|
"""Mirrors test_content_actions._make_deps."""
|
|
if "task" in overrides:
|
|
task = overrides["task"]
|
|
else:
|
|
task = AsyncMock()
|
|
task.get_active_task_for_agent.return_value = None
|
|
|
|
git = overrides.get("git", AsyncMock())
|
|
messaging = overrides.get("messaging", AsyncMock())
|
|
a2a = overrides.get("a2a", AsyncMock())
|
|
journal = overrides.get("journal", AsyncMock())
|
|
workspace = overrides.get("workspace", AsyncMock())
|
|
notifications = overrides.get("notifications", AsyncMock())
|
|
return ContentActionsDeps(
|
|
task=task,
|
|
git=git,
|
|
messaging=messaging,
|
|
a2a=a2a,
|
|
journal=journal,
|
|
workspace=workspace,
|
|
notifications=notifications,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_say_returns_not_authorized_envelope_on_access_denied() -> None:
|
|
"""When messaging.post_to_channel raises ChannelAccessDeniedError, say()
|
|
converts it into a not_authorized Envelope with a writable-channels hint.
|
|
"""
|
|
aid = uuid4()
|
|
msg_svc = AsyncMock()
|
|
msg_svc.post_to_channel.side_effect = ChannelAccessDeniedError(
|
|
agent_id="be-dev-1",
|
|
channel_slug="announcements",
|
|
action="write",
|
|
)
|
|
deps = _make_deps(messaging=msg_svc)
|
|
actions = ContentActions(deps)
|
|
|
|
env = await actions.say(agent_id=aid, channel="announcements", text="hi")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
assert "announcements" in body["message"]
|
|
# Remediation should hint at the channels the agent may write to (or
|
|
# that they have none). The list is resolved from CHANNEL_ACCESS via
|
|
# get_agent_channels using the agent's slug — note the wording is
|
|
# slug-keyed ("channels you may write to") rather than role-keyed,
|
|
# since CHANNEL_ACCESS is keyed by slug not role.
|
|
assert body["remediate"] is not None
|
|
assert "channels you may write to" in body["remediate"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_say_success_path_returns_posted_envelope() -> None:
|
|
"""Sanity: when messaging accepts the post, say() returns the
|
|
standard posted/continue Envelope (no regression in the happy path).
|
|
"""
|
|
aid = uuid4()
|
|
msg_svc = AsyncMock()
|
|
deps = _make_deps(messaging=msg_svc)
|
|
actions = ContentActions(deps)
|
|
|
|
env = await actions.say(agent_id=aid, channel="dev-all", text="hello")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
assert body["status"] == "posted"
|
|
assert body["next"] == "continue"
|
|
msg_svc.post_to_channel.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_say_returns_not_authorized_when_agent_lookup_fails() -> None:
|
|
"""If get_agent_slug returns None (deleted agent), say must fail closed.
|
|
|
|
Pins the I1 fix: post_to_channel raises ChannelAccessDeniedError directly
|
|
when the slug lookup fails, so send_message's `if agent_slug:` can no
|
|
longer skip validate_channel_access for unknown/removed agents. The
|
|
Envelope conversion in say() carries that through to the agent.
|
|
"""
|
|
aid = uuid4()
|
|
msg_svc = AsyncMock()
|
|
msg_svc.post_to_channel.side_effect = ChannelAccessDeniedError(
|
|
agent_id=str(aid),
|
|
channel_slug="dev-all",
|
|
action="write",
|
|
message="agent not found",
|
|
)
|
|
deps = _make_deps(messaging=msg_svc)
|
|
actions = ContentActions(deps)
|
|
|
|
env = await actions.say(agent_id=aid, channel="dev-all", text="hi")
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
assert "dev-all" in body["message"]
|