297 lines
11 KiB
Python
297 lines
11 KiB
Python
|
|
"""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
|