mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(channels): control IM notifications and add WeChat/QQ delivery * fix(scheduler): skip notification preference locks without recipients --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
190 lines
8.8 KiB
Python
190 lines
8.8 KiB
Python
"""Private context writes cannot restore credentials after binding revocation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import uuid
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
from sqlalchemy.sql.dml import Update
|
|
from support.postgres import asyncpg_test_url
|
|
|
|
from app.channels.message_bus import MessageBus
|
|
from app.channels.wechat import WechatChannel
|
|
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher, ChannelCredentialRow
|
|
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
|
|
|
|
|
@pytest_asyncio.fixture(params=["sqlite", "postgres"])
|
|
async def repo(tmp_path, request):
|
|
if request.param == "postgres":
|
|
uri = os.environ.get("TEST_POSTGRES_URI")
|
|
if not uri:
|
|
pytest.skip("requires TEST_POSTGRES_URI (real Postgres connection-context guards)")
|
|
import deerflow.persistence.models # noqa: F401 -- production ORM registration
|
|
from deerflow.persistence.base import Base
|
|
|
|
schema = f"channel_context_{uuid.uuid4().hex}"
|
|
engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema))
|
|
created = False
|
|
try:
|
|
async with engine.begin() as connection:
|
|
await connection.execute(text(f'CREATE SCHEMA "{schema}"'))
|
|
created = True
|
|
async with engine.begin() as connection:
|
|
await connection.run_sync(Base.metadata.create_all)
|
|
yield ChannelConnectionRepository(async_sessionmaker(engine, expire_on_commit=False), cipher=ChannelCredentialCipher.from_key("test-only-context-key"))
|
|
finally:
|
|
try:
|
|
if created:
|
|
async with engine.begin() as connection:
|
|
await connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
|
|
finally:
|
|
await engine.dispose()
|
|
return
|
|
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
|
|
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'channels.db'}", sqlite_dir=str(tmp_path))
|
|
try:
|
|
yield ChannelConnectionRepository(get_session_factory(), cipher=ChannelCredentialCipher.from_key("test-only-context-key"))
|
|
finally:
|
|
await close_engine()
|
|
|
|
|
|
async def _bind(repo, owner="owner-1"):
|
|
return await repo.upsert_connection(owner_user_id=owner, provider="wechat", external_account_id="wx-user", workspace_id="wx-user")
|
|
|
|
|
|
async def _store(repo, connection, **overrides):
|
|
args = {"owner_user_id": "owner-1", "provider": "wechat", "external_account_id": "wx-user", "workspace_id": "wx-user", "extra": {"context": "private-context"}, **overrides}
|
|
return await repo.store_connection_context(connection["id"], **args)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_is_encrypted_and_preserves_existing_credentials(repo):
|
|
connection = await _bind(repo)
|
|
await repo.store_credentials(connection["id"], access_token="access", refresh_token="refresh", token_type="Bearer", extra={"another_context": "keep"})
|
|
assert await _store(repo, connection)
|
|
restored = ChannelConnectionRepository(repo.session_factory, cipher=ChannelCredentialCipher.from_key("test-only-context-key"))
|
|
credentials = await restored.get_credentials(connection["id"])
|
|
assert credentials["access_token"] == "access"
|
|
assert credentials["refresh_token"] == "refresh"
|
|
assert credentials["token_type"] == "Bearer"
|
|
assert credentials["extra"] == {"another_context": "keep", "context": "private-context"}
|
|
assert (await repo.list_connections("owner-1"))[0]["metadata"] == {}
|
|
async with repo.session_factory() as session:
|
|
row = await session.get(ChannelCredentialRow, connection["id"])
|
|
assert "private-context" not in row.encrypted_extra_json
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("override", [{"owner_user_id": "other"}, {"provider": "feishu"}, {"external_account_id": "other"}, {"workspace_id": "group"}])
|
|
async def test_context_write_requires_matching_current_identity(repo, override):
|
|
connection = await _bind(repo)
|
|
assert not await _store(repo, connection, **override)
|
|
assert await repo.get_credentials(connection["id"]) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("change", ["disconnect", "transfer"])
|
|
async def test_delayed_context_write_cannot_restore_revoked_credentials(repo, change):
|
|
connection = await _bind(repo)
|
|
await repo.store_credentials(connection["id"], access_token=None, extra={"context": "old"})
|
|
ready, release = asyncio.Event(), asyncio.Event()
|
|
|
|
async def delayed_write():
|
|
ready.set()
|
|
await release.wait()
|
|
return await _store(repo, connection)
|
|
|
|
pending = asyncio.create_task(delayed_write())
|
|
try:
|
|
await ready.wait()
|
|
if change == "disconnect":
|
|
await repo.disconnect_connection(connection_id=connection["id"], owner_user_id="owner-1")
|
|
else:
|
|
await _bind(repo, "owner-2")
|
|
release.set()
|
|
assert not await pending
|
|
assert await repo.get_credentials(connection["id"]) is None
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(pending, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transfer_waits_for_context_write_then_removes_its_credentials(repo):
|
|
connection = await _bind(repo)
|
|
locked, attempted, release = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
|
|
|
class WriterSession(AsyncSession):
|
|
async def execute(self, statement, *args, **kwargs):
|
|
result = await super().execute(statement, *args, **kwargs)
|
|
if isinstance(statement, Update) and statement.table.name == "channel_connections":
|
|
locked.set()
|
|
await release.wait()
|
|
return result
|
|
|
|
class TransferSession(AsyncSession):
|
|
async def execute(self, statement, *args, **kwargs):
|
|
if isinstance(statement, Update) and statement.table.name == "channel_connections":
|
|
attempted.set()
|
|
return await super().execute(statement, *args, **kwargs)
|
|
|
|
cipher = ChannelCredentialCipher.from_key("test-only-context-key")
|
|
engine = repo.session_factory.kw["bind"]
|
|
writer_repo = ChannelConnectionRepository(async_sessionmaker(engine, class_=WriterSession, expire_on_commit=False), cipher=cipher)
|
|
transfer_repo = ChannelConnectionRepository(async_sessionmaker(engine, class_=TransferSession, expire_on_commit=False), cipher=cipher)
|
|
writer = asyncio.create_task(_store(writer_repo, connection))
|
|
transfer = None
|
|
try:
|
|
await asyncio.wait_for(locked.wait(), 3)
|
|
transfer = asyncio.create_task(_bind(transfer_repo, "owner-2"))
|
|
await asyncio.wait_for(attempted.wait(), 3)
|
|
assert not transfer.done()
|
|
release.set()
|
|
assert await asyncio.wait_for(writer, 3)
|
|
replacement = await asyncio.wait_for(transfer, 3)
|
|
assert await repo.get_credentials(connection["id"]) is None
|
|
assert await repo.get_credentials(replacement["id"]) is None
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(writer, *([transfer] if transfer else []), return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wechat_private_context_survives_channel_and_repository_restart(repo):
|
|
connection = await _bind(repo)
|
|
old = WechatChannel(MessageBus(), {"bot_token": "bot-token", "connection_repo": repo})
|
|
old._publish_inbound_or_drop = AsyncMock()
|
|
await old._handle_update({"message_type": 1, "from_user_id": "wx-user", "context_token": "restored-private-context", "item_list": [{"type": 1, "text_item": {"text": "hello"}}]})
|
|
assert "context_token" not in (await repo.list_connections("owner-1"))[0]["metadata"]
|
|
restored_repo = ChannelConnectionRepository(repo.session_factory, cipher=ChannelCredentialCipher.from_key("test-only-context-key"))
|
|
channel = WechatChannel(MessageBus(), {"bot_token": "bot-token", "connection_repo": restored_repo})
|
|
channel._running = True
|
|
sent = []
|
|
|
|
def transport(request):
|
|
sent.append(json.loads(request.content))
|
|
return httpx.Response(200, json={"ret": 0})
|
|
|
|
channel._client = httpx.AsyncClient(transport=httpx.MockTransport(transport))
|
|
try:
|
|
assert not channel._context_tokens_by_chat
|
|
await channel.send_notification_for_owner(owner_user_id="owner-1", target="wx-user", text_markdown="Done")
|
|
assert sent[0]["msg"]["context_token"] == "restored-private-context"
|
|
await repo.disconnect_connection(connection_id=connection["id"], owner_user_id="owner-1")
|
|
with pytest.raises(RuntimeError, match="no longer connected"):
|
|
await channel.send_notification_for_owner(owner_user_id="owner-1", target="wx-user", text_markdown="Done")
|
|
assert len(sent) == 1
|
|
finally:
|
|
await channel.stop()
|