feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
343
tests/test_slack_approval_owners.py
Normal file
343
tests/test_slack_approval_owners.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""Slack approval ownership is distinct from ordinary inbound access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from coworker.connectors import ConnectorSettings, Gateway, TeamAuth
|
||||
from coworker.connectors.base import InteractionEvent, MessageEvent, SessionSource
|
||||
from coworker.interactions import encode
|
||||
from coworker.providers import ModelCapabilities, ProviderClient
|
||||
from coworker.server import create_app
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
|
||||
class NoTurnsProvider(ProviderClient):
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
raise AssertionError("no model turns expected")
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def _manager(tmp_path) -> SessionManager:
|
||||
return SessionManager(data_dir=tmp_path / "data", provider=NoTurnsProvider())
|
||||
|
||||
|
||||
def _manual_profile(manager: SessionManager, **extra) -> None:
|
||||
manager.secrets.put(
|
||||
"slack:default",
|
||||
{
|
||||
"bot_token": "xoxb-test",
|
||||
"app_token": "xapp-test",
|
||||
"enabled": True,
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_manual_owner_is_required_for_binding_and_implies_allowed(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
_manual_profile(manager)
|
||||
|
||||
denied = manager.set_inbox_binding(
|
||||
"default", channel="slack", target="C_APPROVALS"
|
||||
)
|
||||
assert denied["ok"] is False
|
||||
|
||||
added = manager.set_slack_approval_owner(
|
||||
"U_OWNER", add=True, display_name="Owner"
|
||||
)
|
||||
assert added["ok"] is True
|
||||
profile = manager.secrets.get("slack:default")
|
||||
assert profile["approval_owner_ids"] == ["U_OWNER"]
|
||||
assert profile["allowed_users"] == ["U_OWNER"]
|
||||
|
||||
bound = manager.set_inbox_binding(
|
||||
"default", channel="slack", target="C_APPROVALS"
|
||||
)
|
||||
assert bound["ok"] is True
|
||||
assert manager.disallow_user("slack", "U_OWNER")["ok"] is False
|
||||
assert manager.set_slack_approval_owner("U_OWNER", add=False)["ok"] is False
|
||||
|
||||
manager.set_slack_approval_owner("U_SECOND", add=True)
|
||||
assert manager.set_slack_approval_owner("U_OWNER", add=False)["ok"] is True
|
||||
assert manager.slack_approval_owner_ids() == {"U_SECOND"}
|
||||
|
||||
|
||||
def test_manual_owner_rest_flow_surfaces_identity_and_binding(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
_manual_profile(manager)
|
||||
client = TestClient(create_app(manager))
|
||||
|
||||
added = client.post(
|
||||
"/v1/connectors/slack/approval-owners/add",
|
||||
json={"user_id": "U_OWNER", "name": "Ada"},
|
||||
).json()
|
||||
assert added["ok"] is True
|
||||
|
||||
slack = next(
|
||||
row
|
||||
for row in client.get("/v1/connectors").json()["connectors"]
|
||||
if row["name"] == "slack"
|
||||
)
|
||||
assert slack["approval_owner_ids"] == ["U_OWNER"]
|
||||
assert slack["approval_owner_names"] == {"U_OWNER": "Ada"}
|
||||
|
||||
bound = client.post(
|
||||
"/v1/inbox/routing/binding",
|
||||
json={"name": "default", "channel": "slack", "target": "C_APPROVALS"},
|
||||
).json()
|
||||
assert bound["ok"] is True
|
||||
|
||||
|
||||
def test_relay_binding_uses_installer_identity(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
manager.secrets.put(
|
||||
"slack:default", {"mode": "relay", "enabled": True, "managed": True}
|
||||
)
|
||||
manager.secrets.put(
|
||||
"slack:team:T1",
|
||||
{
|
||||
"bot_token": "xoxb-team",
|
||||
"managed": True,
|
||||
"slack_user_id": "U_INSTALLER",
|
||||
"allowed_users": ["U_INSTALLER"],
|
||||
},
|
||||
)
|
||||
assert manager.set_inbox_binding(
|
||||
"default", channel="slack", target="T1/C_APPROVALS"
|
||||
)["ok"]
|
||||
|
||||
manager.secrets.put(
|
||||
"slack:team:T2",
|
||||
{"bot_token": "xoxb-team-2", "managed": True},
|
||||
)
|
||||
assert not manager.set_inbox_binding(
|
||||
"default", channel="slack", target="T2/C_APPROVALS"
|
||||
)["ok"]
|
||||
|
||||
|
||||
def test_relay_does_not_reuse_dormant_manual_owners_for_bare_target(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
manager.secrets.put(
|
||||
"slack:default",
|
||||
{
|
||||
"mode": "relay",
|
||||
"enabled": True,
|
||||
"managed": True,
|
||||
"bot_token": "xoxb-dormant",
|
||||
"app_token": "xapp-dormant",
|
||||
"approval_owner_ids": ["U_OLD_MANUAL_OWNER"],
|
||||
},
|
||||
)
|
||||
assert manager.slack_approval_owner_ids() == set()
|
||||
assert not manager.set_inbox_binding(
|
||||
"default", channel="slack", target="C_AMBIGUOUS"
|
||||
)["ok"]
|
||||
|
||||
|
||||
def test_nonowner_cannot_resolve_approval_but_can_answer_question(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
_manual_profile(
|
||||
manager,
|
||||
allowed_users=["U_OWNER", "U_MEMBER"],
|
||||
approval_owner_ids=["U_OWNER"],
|
||||
)
|
||||
|
||||
class GatewayStub:
|
||||
def __init__(self):
|
||||
self.rejections = []
|
||||
self.updates = []
|
||||
|
||||
async def reject_interaction(self, event, text=""):
|
||||
self.rejections.append(event.user_id)
|
||||
|
||||
async def update_message(self, *args):
|
||||
self.updates.append(args)
|
||||
|
||||
gateway = GatewayStub()
|
||||
manager.gateway = gateway
|
||||
approval = manager.inbox.add_approval("s1", "Run it?")
|
||||
question = manager.inbox.add_question("s1", "Which one?", options=["A", "B"])
|
||||
|
||||
async def scenario():
|
||||
await manager._on_interaction(
|
||||
InteractionEvent(
|
||||
platform="slack",
|
||||
chat_id="C1",
|
||||
message_id="1",
|
||||
value=encode(approval.id, "allow"),
|
||||
user_id="U_MEMBER",
|
||||
user_name="Member",
|
||||
)
|
||||
)
|
||||
await manager._on_interaction(
|
||||
InteractionEvent(
|
||||
platform="slack",
|
||||
chat_id="C1",
|
||||
message_id="2",
|
||||
value=encode(question.id, "A"),
|
||||
user_id="U_MEMBER",
|
||||
user_name="Member",
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert manager.inbox.get(approval.id).state == "pending"
|
||||
assert manager.inbox.get(question.id).resolution == "A"
|
||||
assert gateway.rejections == ["U_MEMBER"]
|
||||
assert len(gateway.updates) == 1
|
||||
|
||||
|
||||
def test_owner_click_from_a_different_bound_channel_is_rejected(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
_manual_profile(
|
||||
manager,
|
||||
allowed_users=["U_OWNER"],
|
||||
approval_owner_ids=["U_OWNER"],
|
||||
)
|
||||
assert manager.set_inbox_binding(
|
||||
"default", channel="slack", target="C_EXPECTED"
|
||||
)["ok"]
|
||||
item = manager.inbox.add_approval("s1", "Run it?")
|
||||
|
||||
class GatewayStub:
|
||||
def __init__(self):
|
||||
self.rejections = []
|
||||
|
||||
async def reject_interaction(self, event, text=""):
|
||||
self.rejections.append(event.chat_id)
|
||||
|
||||
gateway = GatewayStub()
|
||||
manager.gateway = gateway
|
||||
asyncio.run(
|
||||
manager._on_interaction(
|
||||
InteractionEvent(
|
||||
platform="slack",
|
||||
chat_id="C_OTHER",
|
||||
message_id="1",
|
||||
value=encode(item.id, "allow"),
|
||||
user_id="U_OWNER",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert manager.inbox.get(item.id).state == "pending"
|
||||
assert gateway.rejections == ["C_OTHER"]
|
||||
|
||||
|
||||
def test_gateway_rejects_interaction_from_unallowed_actor():
|
||||
handled = []
|
||||
|
||||
async def handler(event):
|
||||
handled.append(event)
|
||||
|
||||
gateway = Gateway(
|
||||
settings={
|
||||
"slack": ConnectorSettings(
|
||||
platform="slack",
|
||||
enabled=True,
|
||||
teams={"T1": TeamAuth(allowed_users={"U_ALLOWED"})},
|
||||
)
|
||||
},
|
||||
interaction_handler=handler,
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
await gateway._on_interaction(
|
||||
InteractionEvent(
|
||||
platform="slack",
|
||||
chat_id="T1/C1",
|
||||
message_id="1",
|
||||
value="ignored",
|
||||
user_id="U_OTHER",
|
||||
team_id="T1",
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert handled == []
|
||||
|
||||
|
||||
def test_rejected_click_uses_only_slacks_ephemeral_response_url(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
calls.append((url, kwargs))
|
||||
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
gateway = Gateway(settings={})
|
||||
|
||||
async def scenario():
|
||||
await gateway.reject_interaction(
|
||||
InteractionEvent(
|
||||
platform="slack",
|
||||
chat_id="C1",
|
||||
message_id="1",
|
||||
value="ignored",
|
||||
response_url="https://hooks.slack.com/actions/abc",
|
||||
)
|
||||
)
|
||||
await gateway.reject_interaction(
|
||||
InteractionEvent(
|
||||
platform="slack",
|
||||
chat_id="C1",
|
||||
message_id="1",
|
||||
value="ignored",
|
||||
response_url="https://hooks.slack.com.evil.example/actions/abc",
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "https://hooks.slack.com/actions/abc"
|
||||
assert calls[0][1]["json"]["response_type"] == "ephemeral"
|
||||
assert "approval owner" in calls[0][1]["json"]["text"]
|
||||
|
||||
|
||||
def test_slack_reply_token_is_owner_only_for_approvals(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
_manual_profile(
|
||||
manager,
|
||||
allowed_users=["U_OWNER", "U_MEMBER"],
|
||||
approval_owner_ids=["U_OWNER"],
|
||||
)
|
||||
item = manager.inbox.add_approval("s1", "Run it?")
|
||||
|
||||
unauthorized = MessageEvent(
|
||||
text=f"approve [ow:{item.id}]",
|
||||
source=SessionSource("slack", "C1", user_id="U_MEMBER"),
|
||||
)
|
||||
assert manager._resolve_inbox_reply(unauthorized) is True
|
||||
assert manager.inbox.get(item.id).state == "pending"
|
||||
|
||||
owner = MessageEvent(
|
||||
text=f"approve [ow:{item.id}]",
|
||||
source=SessionSource("slack", "C1", user_id="U_OWNER"),
|
||||
)
|
||||
assert manager._resolve_inbox_reply(owner) is True
|
||||
assert manager.inbox.get(item.id).resolution == "allow"
|
||||
|
||||
|
||||
def test_ownerless_legacy_binding_does_not_mirror(tmp_path):
|
||||
manager = _manager(tmp_path)
|
||||
_manual_profile(manager, allowed_users=["U_MEMBER"])
|
||||
manager.inbox_routing.set_binding(
|
||||
"default", channel="slack", target="C_APPROVALS"
|
||||
)
|
||||
item = manager.inbox.add_approval("s1", "Run it?")
|
||||
|
||||
class GatewayStub:
|
||||
def __init__(self):
|
||||
self.deliveries = []
|
||||
|
||||
async def deliver_interactive(self, *args):
|
||||
self.deliveries.append(args)
|
||||
|
||||
gateway = GatewayStub()
|
||||
manager.gateway = gateway
|
||||
asyncio.run(manager.mirror_inbox_item(item))
|
||||
assert gateway.deliveries == []
|
||||
Reference in New Issue
Block a user