ForcePilot/backend/test/unit/channels/test_msteams_auth_audio.py

297 lines
11 KiB
Python
Raw Normal View History

"""MSTeams audio / credentials / probe / oauth / sso 单元测试。"""
from __future__ import annotations
import json
import time
import pytest
from yuxi.channels.adapters.msteams.audio import (
build_voice_activity,
build_tts_activity,
parse_voice_attachment,
)
from yuxi.channels.adapters.msteams.credentials import (
FederatedCredentialError,
DelegatedAuthStore,
)
from yuxi.channels.adapters.msteams.oauth_flow import (
_generate_pkce_code_verifier,
_generate_pkce_code_challenge,
_generate_state,
detect_wsl2,
detect_ssh_session,
needs_manual_oauth,
OAuthPKCEFlow,
)
from yuxi.channels.adapters.msteams.sso import (
is_signin_invoke,
is_token_exchange,
is_verify_state,
extract_signin_token,
build_oauth_card,
build_token_exchange_response,
build_sso_auth_activity,
SSOHandler,
SSOTokenStore,
)
class TestAudioBuildVoiceActivity:
def test_basic(self):
activity = build_voice_activity(b"fake-audio-data")
assert activity["type"] == "message"
assert len(activity["attachments"]) == 1
assert activity["attachments"][0]["contentType"] == "audio/wav"
assert activity["attachments"][0]["contentUrl"].startswith("data:audio/wav;base64,")
def test_with_reply(self):
activity = build_voice_activity(b"data", reply_to_id="msg-1")
assert activity["replyToId"] == "msg-1"
def test_custom_mime(self):
activity = build_voice_activity(b"data", mime_type="audio/mp3", filename="voice.mp3")
assert activity["attachments"][0]["contentType"] == "audio/mp3"
assert activity["attachments"][0]["name"] == "voice.mp3"
class TestAudioBuildTTSActivity:
def test_basic(self):
activity = build_tts_activity("hello world")
assert activity["type"] == "message"
assert activity["text"] == "hello world"
assert activity["channelData"]["speak"] == "hello world"
def test_voice_param(self):
activity = build_tts_activity("hello", voice="en-US-JennyNeural")
assert activity["channelData"]["voice"] == "en-US-JennyNeural"
def test_with_reply(self):
activity = build_tts_activity("hello", reply_to_id="msg-1")
assert activity["replyToId"] == "msg-1"
class TestAudioParseVoiceAttachment:
def test_audio_attachment(self):
result = parse_voice_attachment({
"contentType": "audio/wav",
"contentUrl": "https://example.com/audio.wav",
"name": "recording.wav",
})
assert result is not None
assert result["type"] == "audio"
assert result["url"] == "https://example.com/audio.wav"
def test_non_audio_attachment(self):
result = parse_voice_attachment({"contentType": "image/png"})
assert result is None
def test_missing_fields(self):
result = parse_voice_attachment({"contentType": "audio/mp3"})
assert result is not None
assert result["name"] == "audio"
class TestFederatedCredentialError:
def test_is_exception(self):
with pytest.raises(FederatedCredentialError, match="Certificate file not found"):
raise FederatedCredentialError("Certificate file not found")
class TestDelegatedAuthStore:
@pytest.fixture
def store(self, tmp_path, monkeypatch):
monkeypatch.delenv("MSTEAMS_DELEGATED_STORE_KEY", raising=False)
return DelegatedAuthStore(storage_dir=str(tmp_path))
def test_store_and_get_token(self, store):
store.store_token("user-1", "access-token", "refresh-token", expires_in=3600)
entry = store.get_token("user-1")
assert entry is not None
assert entry["access_token"] == "access-token"
assert entry["refresh_token"] == "refresh-token"
def test_get_nonexistent(self, store):
assert store.get_token("nonexistent") is None
def test_is_expired_new_token(self, store):
store.store_token("user-1", "access", "refresh", expires_in=3600)
assert store.is_expired("user-1") is False
def test_is_expired_nonexistent(self, store):
assert store.is_expired("nonexistent") is True
def test_persistence(self, tmp_path, monkeypatch):
monkeypatch.delenv("MSTEAMS_DELEGATED_STORE_KEY", raising=False)
store1 = DelegatedAuthStore(storage_dir=str(tmp_path))
store1.store_token("user-1", "at", "rt")
store2 = DelegatedAuthStore(storage_dir=str(tmp_path))
entry = store2.get_token("user-1")
assert entry is not None
assert entry["access_token"] == "at"
class TestOAuthPKCE:
def test_generate_verifier(self):
verifier = _generate_pkce_code_verifier()
assert len(verifier) >= 43
def test_generate_challenge(self):
verifier = "test-verifier"
challenge = _generate_pkce_code_challenge(verifier)
assert len(challenge) >= 43
def test_verifier_challenge_consistency(self):
verifier = _generate_pkce_code_verifier()
challenge1 = _generate_pkce_code_challenge(verifier)
challenge2 = _generate_pkce_code_challenge(verifier)
assert challenge1 == challenge2
def test_generate_state(self):
state = _generate_state()
assert len(state) == 64
def test_detect_wsl2(self):
result = detect_wsl2()
assert isinstance(result, bool)
def test_detect_ssh_session(self):
result = detect_ssh_session()
assert isinstance(result, bool)
def test_needs_manual_oauth(self):
result = needs_manual_oauth()
assert isinstance(result, bool)
class TestOAuthPKCEFlow:
def test_build_authorize_url(self):
flow = OAuthPKCEFlow(client_id="test-client-id", tenant_id="common")
url = flow.build_authorize_url()
assert "client_id=test-client-id" in url
assert "code_challenge=" in url
assert "code_challenge_method=S256" in url
assert "response_type=code" in url
def test_build_authorize_url_with_custom_tenant(self):
flow = OAuthPKCEFlow(client_id="test-id", tenant_id="my-tenant")
url = flow.build_authorize_url()
assert "my-tenant" in url
def test_build_authorize_url_with_custom_scopes(self):
flow = OAuthPKCEFlow(client_id="test-id", scopes=["scope1", "scope2"])
url = flow.build_authorize_url()
assert "scope1%20scope2" in url or "scope1+scope2" in url
def test_build_authorize_url_with_custom_port(self):
flow = OAuthPKCEFlow(client_id="test-id", redirect_port=8080)
url = flow.build_authorize_url()
assert "localhost%3A8080" in url or "localhost:8080" in url
class TestSSO:
def test_is_signin_invoke(self):
assert is_signin_invoke({"name": "signin/tokenExchange"}) is True
assert is_signin_invoke({"name": "signin/verifyState"}) is True
assert is_signin_invoke({"name": "other"}) is False
def test_is_token_exchange(self):
assert is_token_exchange({"name": "signin/tokenExchange"}) is True
assert is_token_exchange({"name": "signin/verifyState"}) is False
def test_is_verify_state(self):
assert is_verify_state({"name": "signin/verifyState"}) is True
assert is_verify_state({"name": "signin/tokenExchange"}) is False
def test_extract_signin_token(self):
activity = {"value": {"token": "abc123", "id": "id-1", "state": "state-1"}}
result = extract_signin_token(activity)
assert result["token"] == "abc123"
assert result["id"] == "id-1"
assert result["state"] == "state-1"
def test_extract_signin_token_empty(self):
result = extract_signin_token({})
assert result["token"] == ""
def test_build_oauth_card(self):
card = build_oauth_card("conn-1", title="Login", text="Please login")
assert card["type"] == "AdaptiveCard"
assert len(card["actions"]) == 1
assert "conn-1" in card["actions"][0]["url"]
def test_build_token_exchange_response(self):
resp = build_token_exchange_response("activity-1", 200)
assert resp["id"] == "activity-1"
assert resp["status"] == 200
def test_build_sso_auth_activity(self):
activity = build_sso_auth_activity("success", connection_name="conn-1")
assert activity["text"] == "success"
assert activity["channelData"]["oauthConnectionName"] == "conn-1"
class TestSSOHandler:
@pytest.fixture
def handler(self):
return SSOHandler(connection_name="test-conn", enabled=True)
def test_default_disabled(self):
handler = SSOHandler()
assert handler.enabled is False
def test_add_and_check_verified_state(self, handler):
handler.add_verified_state("state-123")
assert handler.is_state_verified("state-123") is True
assert handler.is_state_verified("state-456") is False
def test_check_sso_authorization_direct(self, handler):
assert handler.check_sso_authorization("direct", "conv-1", dm_policy="open") is True
assert handler.check_sso_authorization("direct", "conv-1", dm_policy="disabled") is False
def test_check_sso_authorization_group(self, handler):
assert handler.check_sso_authorization("group", "conv-1", group_policy="open") is True
assert handler.check_sso_authorization("group", "conv-1", group_policy="disabled") is False
def test_check_sso_authorization_channel(self, handler):
assert handler.check_sso_authorization("channel", "conv-1", group_policy="open") is True
def test_check_sso_authorization_unknown(self, handler):
assert handler.check_sso_authorization("unknown", "conv-1") is False
@pytest.mark.asyncio
async def test_handle_signin_disabled(self):
handler = SSOHandler(enabled=False)
result = await handler.handle_signin({"name": "signin/tokenExchange"})
assert result is None
@pytest.mark.asyncio
async def test_handle_signin_token_exchange(self, handler):
activity = {
"id": "activity-1",
"name": "signin/tokenExchange",
"from": {"aadObjectId": "aad-1", "name": "Test", "id": "user-1"},
"value": {"token": "abc123", "state": "s1"},
}
result = await handler.handle_signin(activity)
assert result is not None
assert result["user_id"] == "aad-1"
@pytest.mark.asyncio
async def test_handle_signin_verify_state(self, handler):
activity = {
"id": "activity-1",
"name": "signin/verifyState",
"value": {"state": "state-abc"},
}
result = await handler.handle_signin(activity)
assert result is not None
assert result["state"] == "state-abc"
assert handler.is_state_verified("state-abc") is True
@pytest.mark.asyncio
async def test_handle_signin_unknown(self, handler):
result = await handler.handle_signin({"name": "unknown"})
assert result is None