426 lines
15 KiB
Python
426 lines
15 KiB
Python
|
|
"""MSTeams accounts / audit / connection_modes / group_mgmt / setup_wizard 单元测试。"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import time
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.msteams.accounts import AccountManager
|
||
|
|
from yuxi.channels.adapters.msteams.audit import (
|
||
|
|
GraphPermissionAuditor,
|
||
|
|
REQUIRED_GRAPH_PERMISSIONS,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.connection_modes import (
|
||
|
|
WebSocketClient,
|
||
|
|
PollingClient,
|
||
|
|
ConnectionModeManager,
|
||
|
|
CONNECTION_MODE_WEBHOOK,
|
||
|
|
CONNECTION_MODE_WEBSOCKET,
|
||
|
|
CONNECTION_MODE_POLLING,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.msteams.setup_wizard import (
|
||
|
|
MSTeamsSetupWizard,
|
||
|
|
_validate_and_normalize_answers,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestAccountManager:
|
||
|
|
@pytest.fixture
|
||
|
|
def mgr(self):
|
||
|
|
return AccountManager()
|
||
|
|
|
||
|
|
def test_register_account(self, mgr):
|
||
|
|
mgr.register_account("acct-1", "app-id", "app-secret", tenant_id="t1", label="Account 1")
|
||
|
|
account = mgr.get_account("acct-1")
|
||
|
|
assert account is not None
|
||
|
|
assert account["app_id"] == "app-id"
|
||
|
|
assert account["app_password"] == "app-secret"
|
||
|
|
assert account["tenant_id"] == "t1"
|
||
|
|
assert account["label"] == "Account 1"
|
||
|
|
assert account["status"] == "registered"
|
||
|
|
|
||
|
|
def test_register_account_default_label(self, mgr):
|
||
|
|
mgr.register_account("acct-1", "app-id", "app-secret")
|
||
|
|
account = mgr.get_account("acct-1")
|
||
|
|
assert account["label"] == "acct-1"
|
||
|
|
|
||
|
|
def test_unregister_account(self, mgr):
|
||
|
|
mgr.register_account("acct-1", "app-id", "app-secret")
|
||
|
|
result = mgr.unregister_account("acct-1")
|
||
|
|
assert result is True
|
||
|
|
assert mgr.get_account("acct-1") is None
|
||
|
|
|
||
|
|
def test_unregister_nonexistent(self, mgr):
|
||
|
|
result = mgr.unregister_account("nonexistent")
|
||
|
|
assert result is False
|
||
|
|
|
||
|
|
def test_get_all_accounts(self, mgr):
|
||
|
|
mgr.register_account("a1", "id1", "pw1")
|
||
|
|
mgr.register_account("a2", "id2", "pw2")
|
||
|
|
accounts = mgr.get_all_accounts()
|
||
|
|
assert len(accounts) == 2
|
||
|
|
|
||
|
|
def test_list_account_ids(self, mgr):
|
||
|
|
mgr.register_account("a1", "id1", "pw1")
|
||
|
|
mgr.register_account("a2", "id2", "pw2")
|
||
|
|
ids = mgr.list_account_ids()
|
||
|
|
assert "a1" in ids
|
||
|
|
assert "a2" in ids
|
||
|
|
|
||
|
|
def test_account_count(self, mgr):
|
||
|
|
assert mgr.account_count == 0
|
||
|
|
mgr.register_account("a1", "id1", "pw1")
|
||
|
|
assert mgr.account_count == 1
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_probe_account_nonexistent(self, mgr):
|
||
|
|
result = await mgr.probe_account("nonexistent")
|
||
|
|
assert result["status"] == "error"
|
||
|
|
assert "not found" in result["message"].lower()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_account(self, mgr):
|
||
|
|
mgr.register_account("acct-1", "app-id", "app-secret", tenant_id="t1")
|
||
|
|
mock_adapter = MagicMock()
|
||
|
|
mock_adapter.config = {}
|
||
|
|
result = await mgr.start_account("acct-1", adapter=mock_adapter)
|
||
|
|
assert result is True
|
||
|
|
assert mock_adapter.config["app_id"] == "app-id"
|
||
|
|
assert mock_adapter.config["app_password"] == "app-secret"
|
||
|
|
assert mock_adapter.config["tenant_id"] == "t1"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_account_nonexistent(self, mgr):
|
||
|
|
result = await mgr.start_account("nonexistent")
|
||
|
|
assert result is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_account_without_adapter(self, mgr):
|
||
|
|
mgr.register_account("acct-1", "id", "pw")
|
||
|
|
result = await mgr.start_account("acct-1")
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_account_no_tenant(self, mgr):
|
||
|
|
mgr.register_account("acct-1", "app-id", "app-secret")
|
||
|
|
mock_adapter = MagicMock()
|
||
|
|
mock_adapter.config = {}
|
||
|
|
result = await mgr.start_account("acct-1", adapter=mock_adapter)
|
||
|
|
assert result is True
|
||
|
|
assert "tenant_id" not in mock_adapter.config
|
||
|
|
|
||
|
|
|
||
|
|
class TestGraphPermissionAuditor:
|
||
|
|
def test_init(self):
|
||
|
|
auditor = GraphPermissionAuditor(token="fake-token", app_id="fake-app")
|
||
|
|
assert auditor._token == "fake-token"
|
||
|
|
assert auditor._app_id == "fake-app"
|
||
|
|
|
||
|
|
def test_parse_oauth2_permissions_empty(self):
|
||
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
||
|
|
result = auditor._parse_oauth2_permissions({"value": []})
|
||
|
|
assert result == set()
|
||
|
|
|
||
|
|
def test_parse_oauth2_permissions_with_data(self):
|
||
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
||
|
|
sp_data = {
|
||
|
|
"value": [{
|
||
|
|
"oauth2PermissionScopes": [
|
||
|
|
{"value": "scope1"},
|
||
|
|
{"value": "scope2"},
|
||
|
|
],
|
||
|
|
}],
|
||
|
|
}
|
||
|
|
result = auditor._parse_oauth2_permissions(sp_data)
|
||
|
|
assert result == {"scope1", "scope2"}
|
||
|
|
|
||
|
|
def test_get_missing_permissions(self):
|
||
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
||
|
|
audit_result = {"perm1": True, "perm2": False, "perm3": False}
|
||
|
|
missing = auditor.get_missing_permissions(audit_result)
|
||
|
|
assert missing == ["perm2", "perm3"]
|
||
|
|
|
||
|
|
def test_get_missing_permissions_all_granted(self):
|
||
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
||
|
|
audit_result = {"perm1": True, "perm2": True}
|
||
|
|
missing = auditor.get_missing_permissions(audit_result)
|
||
|
|
assert missing == []
|
||
|
|
|
||
|
|
def test_required_permissions_list(self):
|
||
|
|
assert "User.Read.All" in REQUIRED_GRAPH_PERMISSIONS
|
||
|
|
assert "ChannelMessage.Send" in REQUIRED_GRAPH_PERMISSIONS
|
||
|
|
|
||
|
|
def test_format_audit_display(self):
|
||
|
|
data = {
|
||
|
|
"scopes": [{"value": "scope1", "type": "Admin"}],
|
||
|
|
"roles": [{"value": "role1", "display_name": "Role 1"}],
|
||
|
|
}
|
||
|
|
result = GraphPermissionAuditor.format_audit_display(data)
|
||
|
|
assert "scope1" in result
|
||
|
|
assert "role1" in result
|
||
|
|
|
||
|
|
def test_format_audit_display_empty(self):
|
||
|
|
result = GraphPermissionAuditor.format_audit_display({})
|
||
|
|
assert result == "No scopes or roles found"
|
||
|
|
|
||
|
|
|
||
|
|
class TestWebSocketClient:
|
||
|
|
@pytest.fixture
|
||
|
|
def ws_client(self):
|
||
|
|
return WebSocketClient(app_id="test-id", app_password="test-secret")
|
||
|
|
|
||
|
|
def test_init_defaults(self, ws_client):
|
||
|
|
assert ws_client._app_id == "test-id"
|
||
|
|
assert ws_client._app_password == "test-secret"
|
||
|
|
assert ws_client._running is False
|
||
|
|
assert ws_client._ws is None
|
||
|
|
|
||
|
|
def test_set_message_handler(self, ws_client):
|
||
|
|
async def handler(msg):
|
||
|
|
pass
|
||
|
|
|
||
|
|
ws_client.set_message_handler(handler)
|
||
|
|
assert ws_client._on_message is handler
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_connect_already_running(self, ws_client):
|
||
|
|
ws_client._running = True
|
||
|
|
await ws_client.connect()
|
||
|
|
assert ws_client._ws is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_activity_closed(self, ws_client):
|
||
|
|
result = await ws_client.send_activity({"type": "message"})
|
||
|
|
assert result is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_sets_running_false(self, ws_client):
|
||
|
|
ws_client._running = True
|
||
|
|
await ws_client.close()
|
||
|
|
assert ws_client._running is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_with_session(self, ws_client):
|
||
|
|
mock_session = MagicMock()
|
||
|
|
mock_session.closed = False
|
||
|
|
mock_session.close = AsyncMock()
|
||
|
|
ws_client._session = mock_session
|
||
|
|
ws_client._ws = Mock()
|
||
|
|
ws_client._ws.closed = False
|
||
|
|
ws_client._ws.close = AsyncMock()
|
||
|
|
await ws_client.close()
|
||
|
|
assert ws_client._session is None
|
||
|
|
assert ws_client._ws is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestPollingClient:
|
||
|
|
@pytest.fixture
|
||
|
|
def poll_client(self):
|
||
|
|
return PollingClient(app_id="test-id", app_password="test-secret")
|
||
|
|
|
||
|
|
def test_init_defaults(self, poll_client):
|
||
|
|
assert poll_client._app_id == "test-id"
|
||
|
|
assert poll_client._running is False
|
||
|
|
|
||
|
|
def test_set_message_handler(self, poll_client):
|
||
|
|
async def handler(msg):
|
||
|
|
pass
|
||
|
|
|
||
|
|
poll_client.set_message_handler(handler)
|
||
|
|
assert poll_client._on_message is handler
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_connect_already_running(self, poll_client):
|
||
|
|
poll_client._running = True
|
||
|
|
await poll_client.connect()
|
||
|
|
assert poll_client._session is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_sets_running_false(self, poll_client):
|
||
|
|
poll_client._running = True
|
||
|
|
await poll_client.close()
|
||
|
|
assert poll_client._running is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_with_session(self, poll_client):
|
||
|
|
mock_session = MagicMock()
|
||
|
|
mock_session.closed = False
|
||
|
|
mock_session.close = AsyncMock()
|
||
|
|
poll_client._session = mock_session
|
||
|
|
await poll_client.close()
|
||
|
|
assert poll_client._session is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestConnectionModeManager:
|
||
|
|
def test_default_mode(self):
|
||
|
|
mgr = ConnectionModeManager({})
|
||
|
|
assert mgr.mode == CONNECTION_MODE_WEBHOOK
|
||
|
|
assert mgr.is_webhook is True
|
||
|
|
assert mgr.is_websocket is False
|
||
|
|
assert mgr.is_polling is False
|
||
|
|
|
||
|
|
def test_custom_mode_websocket(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
||
|
|
assert mgr.mode == CONNECTION_MODE_WEBSOCKET
|
||
|
|
assert mgr.is_websocket is True
|
||
|
|
|
||
|
|
def test_custom_mode_polling(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_POLLING})
|
||
|
|
assert mgr.mode == CONNECTION_MODE_POLLING
|
||
|
|
assert mgr.is_polling is True
|
||
|
|
|
||
|
|
def test_invalid_mode_fallback(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": "invalid"})
|
||
|
|
assert mgr.mode == CONNECTION_MODE_WEBHOOK
|
||
|
|
|
||
|
|
def test_set_message_router(self):
|
||
|
|
mgr = ConnectionModeManager({})
|
||
|
|
|
||
|
|
async def router(msg):
|
||
|
|
pass
|
||
|
|
|
||
|
|
mgr.set_message_router(router)
|
||
|
|
assert mgr._message_router is router
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_webhook_mode(self):
|
||
|
|
mgr = ConnectionModeManager({})
|
||
|
|
await mgr.start("app-id", "app-secret")
|
||
|
|
assert mgr._ws_client is None
|
||
|
|
assert mgr._poll_client is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_websocket_mode(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
||
|
|
with patch.object(WebSocketClient, "connect", new_callable=AsyncMock) as mock_connect:
|
||
|
|
await mgr.start("app-id", "app-secret")
|
||
|
|
assert mgr._ws_client is not None
|
||
|
|
mock_connect.assert_called_once()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_start_polling_mode(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_POLLING})
|
||
|
|
with patch.object(PollingClient, "connect", new_callable=AsyncMock) as mock_connect:
|
||
|
|
await mgr.start("app-id", "app-secret")
|
||
|
|
assert mgr._poll_client is not None
|
||
|
|
mock_connect.assert_called_once()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_stop(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
||
|
|
mgr._ws_client = MagicMock()
|
||
|
|
mgr._ws_client.closed = False
|
||
|
|
mgr._ws_client.close = AsyncMock()
|
||
|
|
mgr._poll_client = MagicMock()
|
||
|
|
mgr._poll_client.closed = False
|
||
|
|
mgr._poll_client.close = AsyncMock()
|
||
|
|
await mgr.stop()
|
||
|
|
assert mgr._ws_client is None
|
||
|
|
assert mgr._poll_client is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_activity_webhook(self):
|
||
|
|
mgr = ConnectionModeManager({})
|
||
|
|
result = await mgr.send_activity({"type": "message"})
|
||
|
|
assert result is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_send_activity_websocket_success(self):
|
||
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
||
|
|
mock_ws = MagicMock()
|
||
|
|
mock_ws.send_activity = AsyncMock(return_value=True)
|
||
|
|
mgr._ws_client = mock_ws
|
||
|
|
result = await mgr.send_activity({"type": "message"})
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestSetupWizardNonInteractive:
|
||
|
|
def test_basic_answers(self):
|
||
|
|
wizard = MSTeamsSetupWizard()
|
||
|
|
config = wizard.run_non_interactive({
|
||
|
|
"app_id": "test-app",
|
||
|
|
"app_password": "test-pw",
|
||
|
|
"dm_policy": "open",
|
||
|
|
"group_policy": "open",
|
||
|
|
"streaming_mode": "block",
|
||
|
|
})
|
||
|
|
assert config["app_id"] == "test-app"
|
||
|
|
assert config["dm_policy"] == "open"
|
||
|
|
|
||
|
|
def test_allow_list_parsing(self):
|
||
|
|
wizard = MSTeamsSetupWizard()
|
||
|
|
config = wizard.run_non_interactive({
|
||
|
|
"allow_from": "user-1,user-2 , user-3",
|
||
|
|
"dm_policy": "allowlist",
|
||
|
|
})
|
||
|
|
assert config["allow_from"] == ["user-1", "user-2", "user-3"]
|
||
|
|
|
||
|
|
def test_allow_list_as_list(self):
|
||
|
|
wizard = MSTeamsSetupWizard()
|
||
|
|
config = wizard.run_non_interactive({
|
||
|
|
"allow_from": ["user-1", "user-2"],
|
||
|
|
"dm_policy": "allowlist",
|
||
|
|
})
|
||
|
|
assert config["allow_from"] == ["user-1", "user-2"]
|
||
|
|
|
||
|
|
def test_bool_keys(self):
|
||
|
|
wizard = MSTeamsSetupWizard()
|
||
|
|
config = wizard.run_non_interactive({
|
||
|
|
"feedback_enabled": True,
|
||
|
|
"welcome_card": False,
|
||
|
|
})
|
||
|
|
assert config["feedback_enabled"] is True
|
||
|
|
assert config["welcome_card"] is False
|
||
|
|
|
||
|
|
def test_oauth_enabled(self):
|
||
|
|
wizard = MSTeamsSetupWizard()
|
||
|
|
config = wizard.run_non_interactive({
|
||
|
|
"oauth_enabled": True,
|
||
|
|
"oauth_scopes": ["scope1"],
|
||
|
|
})
|
||
|
|
assert config["delegated_auth"]["enabled"] is True
|
||
|
|
assert "scope1" in config["delegated_auth"]["scopes"]
|
||
|
|
|
||
|
|
def test_sso_connection(self):
|
||
|
|
wizard = MSTeamsSetupWizard()
|
||
|
|
config = wizard.run_non_interactive({
|
||
|
|
"sso_enabled": True,
|
||
|
|
"sso_connection_name": "my-conn",
|
||
|
|
})
|
||
|
|
assert config["sso"]["enabled"] is True
|
||
|
|
assert config["sso"]["connection_name"] == "my-conn"
|
||
|
|
|
||
|
|
def test_save_config(self, tmp_path):
|
||
|
|
wizard = MSTeamsSetupWizard({"app_id": "test"})
|
||
|
|
filepath = wizard.save_config(str(tmp_path / "config.json"))
|
||
|
|
assert "config.json" in filepath
|
||
|
|
|
||
|
|
def test_existing_config_preserved(self):
|
||
|
|
wizard = MSTeamsSetupWizard({"app_id": "old-id", "extra_key": "extra"})
|
||
|
|
config = wizard.run_non_interactive({"app_id": "new-id"})
|
||
|
|
assert config["app_id"] == "new-id"
|
||
|
|
assert config["extra_key"] == "extra"
|
||
|
|
|
||
|
|
|
||
|
|
class TestValidateAnswers:
|
||
|
|
def test_str_keys(self):
|
||
|
|
result = _validate_and_normalize_answers({
|
||
|
|
"app_id": "test-id",
|
||
|
|
"dm_policy": "open",
|
||
|
|
})
|
||
|
|
assert result["app_id"] == "test-id"
|
||
|
|
assert result["dm_policy"] == "open"
|
||
|
|
|
||
|
|
def test_empty_str_skipped(self):
|
||
|
|
result = _validate_and_normalize_answers({"app_id": ""})
|
||
|
|
assert "app_id" not in result
|
||
|
|
|
||
|
|
def test_group_allow_from_comma(self):
|
||
|
|
result = _validate_and_normalize_answers({"group_allow_from": "g1,g2,g3"})
|
||
|
|
assert result["group_allow_from"] == ["g1", "g2", "g3"]
|
||
|
|
|
||
|
|
def test_invalid_bool_cast(self):
|
||
|
|
result = _validate_and_normalize_answers({"feedback_enabled": "yes"})
|
||
|
|
assert result["feedback_enabled"] is True
|