diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2df70ee..73c0511b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,8 +3,4 @@ repos: rev: 22.3.0 # Use the latest stable version of Black hooks: - id: black - args: [--line-length=88] # Adjust line length as needed -- repo: https://github.com/pycqa/flake8 - rev: 7.2.0 # pick a git hash / tag to point to - hooks: - - id: flake8 \ No newline at end of file + args: [--line-length=88] # Adjust line length as needed \ No newline at end of file diff --git a/tests/activity/tools/__init__.py b/tests/.test_env similarity index 100% rename from tests/activity/tools/__init__.py rename to tests/.test_env diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..5836ce59 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,91 @@ +# Agents SDK for Python Testing + +This document serves as a quick guide to the utilities we provide internally to test this SDK. More information will come with work on integration testing. + +## Storage Tests + +More info soon. For now, there are flags defined in the code that dictate whether the Cosmos DB and the Blob storage tests are run, as these tests rely on local emulators. + +## Mocking + +When using a class or function that takes a `mocker` argument, it is highly recommended that you pass in the Pytest fixture obtained either in: + +a test + +```python + def test_thing(self, some_fixture, mocker): + mock_UserTokenClient(mocker) +``` + +the setup method of a class + +```python +class TestThing: + + def setup_method(self, mocker): + self.msal_auth = MockMsalAuth(mocker) +``` + +or in another fixture + +```python + @pytest.fixture + def user_token_client(self, mocker): + return mock_OAuthFlow(mocker, get_user_token_return="token") +``` + +This ensures Pytest will correctly manage the lifetime of your mocking logic to occur within the score of the individual test or class. + +## Directory Structure + + +### Module Names + +This directory follows the same structure as the package structure. If there is a test module that tests the functionality of a module in the packages source code, then please use the format `test_{ORIGINAL_MODULE}.py` file naming convention. If a module is an extra module that only exists in testing and has no counterpart in the packages source code, then it is encouraged that you prefix it with an underscore. The only exception is if a parent (or ancestor) directory of the testing Python file already has an underscore prefix. For example: + +``` +microsoft_agents/hosting/core + __init__.py + + _common/ + __init__.py + bar.py + + app/ + __init__.py + _helper.py + test_agent_application.py + + _foo.py + test_turn_context.py + test_utils.py + +``` + +### `tests/_common` + +This directory contains common utilities for testing. + +### `tests/_common/_tests` + +Tests for testing utilities. + +### `tests/_common/data` + +Here, we encourage defining the data within the constructors of a class. I guess this helps clean up imports, but the primary purpose is to prevent tests from mutating these testing constants and interfering with other tests. The `test_defaults.py` module defines defaults for fundamental variables such as `user_id` and `channel_id`. These defaults are used in tests as well as other data constructors in `tests/_common/data`. For instance `test_flow_data.py` builds on those defaults to define FlowState objects for testing, and then `tests/_common/storage_data` builds even further on that. + +### `tests/_common/storage` + +Storage related functionality. This was the first shared testing utility module, so this can eventually be reorganized into the rest of the `tests/_common` structure. + +### `tests/_common/fixtures` + +This directory holds common fixtures that might be useful. For instance, `FlowStateFixtures` provides fixtures that are useful for testing both the OAuthFlow and Authorization classes, and it relies on `tests/_common/data/test_flow_data.py` module. + +### `tests/_common/testing_objects` + +This directory contains implementations and functions to construct objects that implement/extend core Protocols or mock existing functionality. In this repo, `testing_{class_name}.py` modules contain simple implementations with predictable behavior while `mock_{class_name}.py` usually contain `mock_{class_name}` and `mock_class_{class_name}` functions that wrap existing instances/classes with Pytest's mocking capabilities. + +### `tests/_integration` + +At the moment, there is no actual real integration testing, but there is a foundation that lets you define an environmental setup and then run a "sample" on that environment. \ No newline at end of file diff --git a/tests/_common/__init__.py b/tests/_common/__init__.py index e69de29b..bb8ba4f3 100644 --- a/tests/_common/__init__.py +++ b/tests/_common/__init__.py @@ -0,0 +1,5 @@ +from .approx_equal import approx_eq + +__all__ = [ + "approx_eq", +] diff --git a/tests/hosting_core/tools/__init__.py b/tests/_common/_tests/__init__.py similarity index 100% rename from tests/hosting_core/tools/__init__.py rename to tests/_common/_tests/__init__.py diff --git a/tests/_common/_tests/data/__init__.py b/tests/_common/_tests/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/data/test_flow_states.py b/tests/_common/_tests/data/test_flow_states.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/data/test_storage_data.py b/tests/_common/_tests/data/test_storage_data.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/storage/__init__.py b/tests/_common/_tests/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/test_utils.py b/tests/_common/_tests/storage/test_storage_utils.py similarity index 100% rename from tests/hosting_core/test_utils.py rename to tests/_common/_tests/storage/test_storage_utils.py diff --git a/tests/_common/_tests/testing_objects/__init__.py b/tests/_common/_tests/testing_objects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/activity/__init__.py b/tests/_common/_tests/testing_objects/activity/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/adapters/__init__.py b/tests/_common/_tests/testing_objects/adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/adapters/test_testing_adapter.py b/tests/_common/_tests/testing_objects/adapters/test_testing_adapter.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/adapters/test_testing_simple_adapter.py b/tests/_common/_tests/testing_objects/adapters/test_testing_simple_adapter.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/mocks/__init__.py b/tests/_common/_tests/testing_objects/mocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/mocks/test_mock_msal_auth.py b/tests/_common/_tests/testing_objects/mocks/test_mock_msal_auth.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/_tests/testing_objects/mocks/test_mock_oauth_flow.py b/tests/_common/_tests/testing_objects/mocks/test_mock_oauth_flow.py new file mode 100644 index 00000000..f1e6474b --- /dev/null +++ b/tests/_common/_tests/testing_objects/mocks/test_mock_oauth_flow.py @@ -0,0 +1,3 @@ +class TestMockOAuthFlow: + def test_mock_oauth_flow(self): + pass diff --git a/tests/_common/_tests/testing_objects/mocks/test_mock_user_token_client.py b/tests/_common/_tests/testing_objects/mocks/test_mock_user_token_client.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/approx_equal.py b/tests/_common/approx_equal.py new file mode 100644 index 00000000..8708445c --- /dev/null +++ b/tests/_common/approx_equal.py @@ -0,0 +1,2 @@ +def approx_eq(a: float, b: float, tol: float = 1e-9) -> bool: + return abs(a - b) <= tol diff --git a/tests/_common/data/__init__.py b/tests/_common/data/__init__.py new file mode 100644 index 00000000..11754a85 --- /dev/null +++ b/tests/_common/data/__init__.py @@ -0,0 +1,15 @@ +from .test_defaults import TEST_DEFAULTS +from .test_auth_data import ( + TEST_AUTH_DATA, + create_test_auth_handler, +) +from .test_storage_data import TEST_STORAGE_DATA +from .test_flow_data import TEST_FLOW_DATA + +__all__ = [ + "TEST_DEFAULTS", + "TEST_AUTH_DATA", + "TEST_STORAGE_DATA", + "TEST_FLOW_DATA", + "create_test_auth_handler", +] diff --git a/tests/_common/data/test_auth_data.py b/tests/_common/data/test_auth_data.py new file mode 100644 index 00000000..024fda0a --- /dev/null +++ b/tests/_common/data/test_auth_data.py @@ -0,0 +1,41 @@ +from microsoft_agents.hosting.core import AuthHandler + + +def create_test_auth_handler( + name: str, obo: bool = False, title: str = None, text: str = None +) -> AuthHandler: + """ + Creates a test AuthHandler instance with standardized connection names. + + This helper function simplifies the creation of AuthHandler objects for testing + by automatically generating connection names based on the provided name and + optionally including On-Behalf-Of (OBO) connection configuration. + + Args: + name: Base name for the auth handler, used to generate connection names + obo: Whether to include On-Behalf-Of connection configuration + title: Optional title for the auth handler + text: Optional descriptive text for the auth handler + + Returns: + AuthHandler: Configured auth handler instance with test-friendly connection names + """ + return AuthHandler( + name, + abs_oauth_connection_name=f"{name}-abs-connection", + obo_connection_name=f"{name}-obo-connection" if obo else None, + title=title, + text=text, + ) + + +class TEST_AUTH_DATA: + def __init__(self): + + self.auth_handler: AuthHandler = create_test_auth_handler("graph") + + self.auth_handlers: dict[str, AuthHandler] = { + "graph": create_test_auth_handler("graph"), + "github": create_test_auth_handler("github", obo=True), + "slack": create_test_auth_handler("slack", obo=True), + } diff --git a/tests/_common/data/test_defaults.py b/tests/_common/data/test_defaults.py new file mode 100644 index 00000000..7422d253 --- /dev/null +++ b/tests/_common/data/test_defaults.py @@ -0,0 +1,20 @@ +from microsoft_agents.activity import Activity, ActivityTypes + +from microsoft_agents.hosting.core import ( + AuthHandler, + TurnContext, +) + + +class TEST_DEFAULTS: + def __init__(self): + + self.token = "__token" + self.channel_id = "__channel_id" + self.user_id = "__user_id" + self.bot_url = "https://botframework.com" + self.ms_app_id = "__ms_app_id" + self.abs_oauth_connection_name = "__connection_name" + self.missing_abs_oauth_connection_name = "__missing_connection_name" + + self.auth_handlers = [AuthHandler()] diff --git a/tests/_common/data/test_flow_data.py b/tests/_common/data/test_flow_data.py new file mode 100644 index 00000000..6cb0c7c3 --- /dev/null +++ b/tests/_common/data/test_flow_data.py @@ -0,0 +1,143 @@ +from datetime import datetime + +from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag + +from tests._common.storage import MockStoreItem +from tests._common.data.test_defaults import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + +DEF_FLOW_ARGS = { + "ms_app_id": DEFAULTS.ms_app_id, + "channel_id": DEFAULTS.channel_id, + "user_id": DEFAULTS.user_id, + "connection": DEFAULTS.abs_oauth_connection_name, +} + + +class TEST_FLOW_DATA: + def __init__(self): + + self.not_started = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.NOT_STARTED, + attempts_remaining=1, + user_token="____", + expiration=datetime.now().timestamp() + 1000000, + ) + + self.started = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.BEGIN, + attempts_remaining=1, + user_token="____", + expiration=datetime.now().timestamp() + 1000000, + ) + + self.started_one_retry = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.BEGIN, + attempts_remaining=2, + user_token="____", + expiration=datetime.now().timestamp() + 1000000, + ) + + self.active = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.CONTINUE, + attempts_remaining=2, + user_token="__token", + expiration=datetime.now().timestamp() + 1000000, + ) + + self.active_one_retry = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.CONTINUE, + attempts_remaining=1, + user_token="__token", + expiration=datetime.now().timestamp() + 1000000, + ) + self.active_exp = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.CONTINUE, + attempts_remaining=2, + user_token="__token", + expiration=datetime.now().timestamp(), + ) + self.completed = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.COMPLETE, + attempts_remaining=2, + user_token="test_token", + expiration=datetime.now().timestamp() + 1000000, + ) + self.fail_by_attempts = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.FAILURE, + attempts_remaining=0, + expiration=datetime.now().timestamp() + 1000000, + ) + + self.fail_by_exp = FlowState( + **DEF_FLOW_ARGS, + tag=FlowStateTag.FAILURE, + attempts_remaining=2, + expiration=0, + ) + + @staticmethod + def model_copy_list(lst): + return [flow_state.model_copy() for flow_state in lst] + + def all_flows(self): + return self.model_copy_list( + [ + self.started, + self.started_one_retry, + self.active, + self.active_one_retry, + self.active_exp, + self.completed, + self.fail_by_attempts, + self.fail_by_exp, + ] + ) + + def failed_flows(self): + return self.model_copy_list( + [ + self.active_exp, + self.fail_by_attempts, + self.fail_by_exp, + ] + ) + + def active_flows(self): + return self.model_copy_list( + [ + self.started, + self.started_one_retry, + self.active, + self.active_one_retry, + ] + ) + + def inactive_flows(self): + return self.model_copy_list( + [ + self.active_exp, + self.completed, + self.fail_by_attempts, + self.fail_by_exp, + ] + ) + + +def flow_key(channel_id, user_id, handler_id): + return f"auth/{channel_id}/{user_id}/{handler_id}" + + +def update_flow_state_handler(flow_state, handler): + flow_state = flow_state.model_copy() + flow_state.auth_handler_id = handler + return flow_state diff --git a/tests/_common/data/test_storage_data.py b/tests/_common/data/test_storage_data.py new file mode 100644 index 00000000..35b6a8d1 --- /dev/null +++ b/tests/_common/data/test_storage_data.py @@ -0,0 +1,50 @@ +from tests._common.storage import MockStoreItem + +from .test_flow_data import ( + TEST_FLOW_DATA, + FlowState, + update_flow_state_handler, + flow_key, +) + +FLOW_DATA = TEST_FLOW_DATA() + + +class TEST_STORAGE_DATA: + def __init__(self): + + self.dict = { + "user_id": MockStoreItem({"id": "123"}), + "session_id": MockStoreItem({"id": "abc"}), + flow_key("webchat", "Alice", "graph"): update_flow_state_handler( + FLOW_DATA.completed, "graph" + ), + flow_key("webchat", "Alice", "github"): update_flow_state_handler( + FLOW_DATA.active_one_retry, "github" + ), + flow_key("teams", "Alice", "graph"): update_flow_state_handler( + FLOW_DATA.started, "graph" + ), + flow_key("webchat", "Bob", "graph"): update_flow_state_handler( + FLOW_DATA.active_exp, "graph" + ), + flow_key("teams", "Bob", "slack"): update_flow_state_handler( + FLOW_DATA.started, "slack" + ), + flow_key("webchat", "Chuck", "github"): update_flow_state_handler( + FLOW_DATA.fail_by_attempts, "github" + ), + } + + def get_init_data(self): + data = self.dict.copy() + for key, value in data.items(): + data[key] = value.model_copy() if isinstance(value, FlowState) else value + return data + + +def update_data_with_flow_state(data, channel_id, user_id, auth_handler_id, flow_state): + data = data.copy() + key = f"auth/{channel_id}/{user_id}/{auth_handler_id}" + data[key] = flow_state.model_copy() + return data diff --git a/tests/_common/fixtures/__init__.py b/tests/_common/fixtures/__init__.py new file mode 100644 index 00000000..1e5c886a --- /dev/null +++ b/tests/_common/fixtures/__init__.py @@ -0,0 +1,5 @@ +from .flow_state_fixtures import FlowStateFixtures + +__all__ = [ + "FlowStateFixtures", +] diff --git a/tests/_common/fixtures/flow_state_fixtures.py b/tests/_common/fixtures/flow_state_fixtures.py new file mode 100644 index 00000000..4ce502d8 --- /dev/null +++ b/tests/_common/fixtures/flow_state_fixtures.py @@ -0,0 +1,45 @@ +import pytest + +from microsoft_agents.hosting.core import FlowStateTag + +from tests._common.data import TEST_FLOW_DATA + +FLOW_STATES = TEST_FLOW_DATA() + + +class FlowStateFixtures: + @pytest.fixture(params=FLOW_STATES.all_flows()) + def flow_state(self, request): + return request.param.model_copy() + + @pytest.fixture(params=FLOW_STATES.failed_flows()) + def failed_flow_state(self, request): + return request.param.model_copy() + + @pytest.fixture(params=FLOW_STATES.inactive_flows()) + def inactive_flow_state(self, request): + return request.param.model_copy() + + @pytest.fixture( + params=[ + flow_state + for flow_state in FLOW_STATES.inactive_flows() + if flow_state.tag != FlowStateTag.COMPLETE + ] + ) + def inactive_flow_state_not_completed(self, request): + return request.param.model_copy() + + @pytest.fixture(params=FLOW_STATES.active_flows()) + def active_flow_state(self, request): + return request.param.model_copy() + + @pytest.fixture( + params=[ + flow_state + for flow_state in FLOW_STATES.inactive_flows() + if flow_state.tag != FlowStateTag.COMPLETE + ] + ) + def sample_inactive_flow_state_not_completed(self, request): + return request.param.model_copy() diff --git a/tests/_common/storage/__init__.py b/tests/_common/storage/__init__.py index e69de29b..0b0912c5 100644 --- a/tests/_common/storage/__init__.py +++ b/tests/_common/storage/__init__.py @@ -0,0 +1,3 @@ +from .utils import MockStoreItem, StorageBaseline + +__all__ = ["MockStoreItem", "StorageBaseline"] diff --git a/tests/_common/storage/utils.py b/tests/_common/storage/utils.py index 9a8f95ae..5a8de601 100644 --- a/tests/_common/storage/utils.py +++ b/tests/_common/storage/utils.py @@ -4,13 +4,10 @@ from abc import ABC from typing import Any -from microsoft_agents.hosting.core.storage import ( - Storage, - StoreItem, - MemoryStorage -) +from microsoft_agents.hosting.core.storage import Storage, StoreItem, MemoryStorage from microsoft_agents.hosting.core.storage._type_aliases import JSON + class MockStoreItem(StoreItem): """Test implementation of StoreItem for testing purposes""" diff --git a/tests/hosting_core/tools/testing_flow.py b/tests/_common/test_client.py similarity index 96% rename from tests/hosting_core/tools/testing_flow.py rename to tests/_common/test_client.py index 26cd30d2..01d09ff4 100644 --- a/tests/hosting_core/tools/testing_flow.py +++ b/tests/_common/test_client.py @@ -8,13 +8,16 @@ from functools import reduce from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core import Connections T = TypeVar("T") -class TestingFlow: +# currently unused (probably outdated) +# but may be useful to bring up-to-date for future testing scenarios +class TestClient: """ - A mock channel that can be used for unit testing of agent logic. + A testing channel that connects directly to an adapter. You can use this class to mimic input from a user or a channel to validate that the agent or adapter responds as expected. @@ -33,18 +36,8 @@ def __init__(self, adapter, callback=None): self._test_task = asyncio.create_task(asyncio.sleep(0)) @classmethod - def _create_from_flow(cls, task, flow): - """ - Creates a new TestingFlow from an existing flow. - - Args: - task: The task to add to the tasks in the existing flow. - flow: The flow to build up from. - - Returns: - A new TestingFlow instance. - """ - new_flow = cls(flow._adapter, flow._callback) + def _clone(cls, task, client): + new_flow = cls(client._adapter, client._callback) new_flow._test_task = task return new_flow diff --git a/tests/_common/testing_objects/__init__.py b/tests/_common/testing_objects/__init__.py new file mode 100644 index 00000000..7e36b7e2 --- /dev/null +++ b/tests/_common/testing_objects/__init__.py @@ -0,0 +1,29 @@ +from .adapters import TestingAdapter + +from .mocks import ( + MockMsalAuth, + mock_OAuthFlow, + mock_class_OAuthFlow, + mock_UserTokenClient, + mock_class_UserTokenClient, +) + +from .testing_authorization import TestingAuthorization +from .testing_connection_manager import TestingConnectionManager +from .testing_custom_state import TestingCustomState +from .testing_token_provider import TestingTokenProvider +from .testing_user_token_client import TestingUserTokenClient + +__all__ = [ + "MockMsalAuth", + "mock_OAuthFlow", + "mock_class_OAuthFlow", + "mock_UserTokenClient", + "mock_class_UserTokenClient", + "TestingAuthorization", + "TestingConnectionManager", + "TestingCustomState", + "TestingTokenProvider", + "TestingUserTokenClient", + "TestingAdapter", +] diff --git a/tests/_common/testing_objects/adapters/__init__.py b/tests/_common/testing_objects/adapters/__init__.py new file mode 100644 index 00000000..4670c1df --- /dev/null +++ b/tests/_common/testing_objects/adapters/__init__.py @@ -0,0 +1,3 @@ +from .testing_adapter import TestingAdapter + +__all__ = ["TestingAdapter"] diff --git a/tests/hosting_core/tools/testing_adapter.py b/tests/_common/testing_objects/adapters/testing_adapter.py similarity index 98% rename from tests/hosting_core/tools/testing_adapter.py rename to tests/_common/testing_objects/adapters/testing_adapter.py index 780f9b11..f753f78b 100644 --- a/tests/hosting_core/tools/testing_adapter.py +++ b/tests/_common/testing_objects/adapters/testing_adapter.py @@ -21,11 +21,10 @@ ) from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents.hosting.core.connector import UserTokenClient -AgentCallbackHandler = Callable[[TurnContext], Awaitable] +from ..testing_user_token_client import TestingUserTokenClient -from .mock_user_token_client import MockUserTokenClient +AgentCallbackHandler = Callable[[TurnContext], Awaitable] class TestingAdapter(ChannelAdapter): @@ -53,7 +52,7 @@ def __init__( self._send_trace_activity = send_trace_activity self._conversation_lock = asyncio.Lock() self._active_queue_lock = asyncio.Lock() - self._user_token_client = MockUserTokenClient() + self._user_token_client = TestingUserTokenClient() self._next_id = 0 self._queued_requests = deque() @@ -120,7 +119,7 @@ def create_conversation( service_url="https://test.com", conversation=ConversationAccount(is_group=False, id=name, name=name), user=ChannelAccount(id=user.lower(), name=user), - agent=ChannelAccount(id=agent.lower(), name=agent), + bot=ChannelAccount(id=agent.lower(), name=agent), locale="en-us", ) diff --git a/tests/_common/testing_objects/http/__init__.py b/tests/_common/testing_objects/http/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/testing_objects/http/mock_abs_api.py b/tests/_common/testing_objects/http/mock_abs_api.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_common/testing_objects/http/testing_channel_service_client_factory.py b/tests/_common/testing_objects/http/testing_channel_service_client_factory.py new file mode 100644 index 00000000..8dd54e97 --- /dev/null +++ b/tests/_common/testing_objects/http/testing_channel_service_client_factory.py @@ -0,0 +1,86 @@ +from typing import Optional + +from microsoft_agents.hosting.core.authorization import ( + AuthenticationConstants, + AnonymousTokenProvider, + ClaimsIdentity, + Connections, +) +from microsoft_agents.hosting.core.authorization import AccessTokenProviderBase +from microsoft_agents.hosting.core.connector import ConnectorClientBase +from microsoft_agents.hosting.core.connector.client import UserTokenClient +from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient + +from .channel_service_client_factory_base import ChannelServiceClientFactoryBase +from .testing_connector_client import TestingConnectorClient + + +class TestingRestChannelServiceClientFactory(ChannelServiceClientFactoryBase): + _ANONYMOUS_TOKEN_PROVIDER = AnonymousTokenProvider() + + def __init__( + self, + mocker, + connection_manager: Connections, + token_service_endpoint=AuthenticationConstants.AGENTS_SDK_OAUTH_URL, + token_service_audience=AuthenticationConstants.AGENTS_SDK_SCOPE, + connector_client_class: type[BaseConnectorClient] = TestingConnectorClient, + user_token_client_class: type[BaseUserTokenClient] = TestingUserTokenClient, + ) -> None: + self._mocker = mocker + self._connection_manager = connection_manager + self._token_service_endpoint = token_service_endpoint + self._token_service_audience = token_service_audience + self._connector_client_class = connector_client_class + self._user_token_client_class = user_token_client_class + + async def create_connector_client( + self, + claims_identity: ClaimsIdentity, + service_url: str, + audience: str, + scopes: Optional[list[str]] = None, + use_anonymous: bool = False, + ) -> ConnectorClientBase: + if not service_url: + raise TypeError( + "RestChannelServiceClientFactory.create_connector_client: service_url can't be None or Empty" + ) + if not audience: + raise TypeError( + "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" + ) + + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_token_provider(claims_identity, service_url) + if not use_anonymous + else self._ANONYMOUS_TOKEN_PROVIDER + ) + + token = await token_provider.get_access_token( + audience, scopes or [f"{audience}/.default"] + ) + + return self._connector_client_class( + endpoint=service_url, + token=token, + ) + + async def create_user_token_client( + self, claims_identity: ClaimsIdentity, use_anonymous: bool = False + ) -> UserTokenClient: + token_provider = ( + self._connection_manager.get_token_provider( + claims_identity, self._token_service_endpoint + ) + if not use_anonymous + else self._ANONYMOUS_TOKEN_PROVIDER + ) + + token = await token_provider.get_access_token( + self._token_service_audience, [f"{self._token_service_audience}/.default"] + ) + return self._user_token_client_class( + endpoint=self._token_service_endpoint, + token=token, + ) diff --git a/tests/_common/testing_objects/http/testing_client_session.py b/tests/_common/testing_objects/http/testing_client_session.py new file mode 100644 index 00000000..a00a89de --- /dev/null +++ b/tests/_common/testing_objects/http/testing_client_session.py @@ -0,0 +1,2 @@ +class TestingClientSessionBase: + pass diff --git a/tests/_common/testing_objects/http/testing_connector_client.py b/tests/_common/testing_objects/http/testing_connector_client.py new file mode 100644 index 00000000..fd797814 --- /dev/null +++ b/tests/_common/testing_objects/http/testing_connector_client.py @@ -0,0 +1,40 @@ +from microsft_agents.hosting.core import ( + AgentAuthConfiguration, + AccessTokenProviderBase, + TeamsConnectorClient, +) + +from tests._common.testing_objects.http.testing_client_session import ( + TestingClientSession, +) + + +class TestingConnectorClient(TeamsConnectorClient): + """Teams Connector Client for interacting with Teams-specific APIs.""" + + @classmethod + async def create_client_with_auth_async( + cls, + base_url: str, + auth_config: AgentAuthConfiguration, + auth_provider: AccessTokenProviderBase, + scope: str, + ) -> "TeamsConnectorClient": + """ + Creates a new instance of TeamsConnectorClient with authentication. + + :param base_url: The base URL for the API. + :param auth_config: The authentication configuration. + :param auth_provider: The authentication provider. + :param scope: The scope for the authentication token. + :return: A new instance of TeamsConnectorClient. + """ + session = TestingClientSession( + base_url=base_url, headers={"Accept": "application/json"} + ) + + token = await auth_provider.get_access_token(auth_config, scope) + if len(token) > 1: + session.headers.update({"Authorization": f"Bearer {token}"}) + + return cls(session) diff --git a/tests/_common/testing_objects/mocks/__init__.py b/tests/_common/testing_objects/mocks/__init__.py new file mode 100644 index 00000000..786a79c8 --- /dev/null +++ b/tests/_common/testing_objects/mocks/__init__.py @@ -0,0 +1,10 @@ +from .mock_msal_auth import MockMsalAuth +from .mock_oauth_flow import mock_OAuthFlow, mock_class_OAuthFlow +from .mock_user_token_client import mock_UserTokenClient, mock_class_UserTokenClient + +__all__ = [ + "MockMsalAuth", + "mock_OAuthFlow", + "mock_class_OAuthFlow", + "mock_UserTokenClient", +] diff --git a/tests/_common/testing_objects/mocks/mock_msal_auth.py b/tests/_common/testing_objects/mocks/mock_msal_auth.py new file mode 100644 index 00000000..44a94025 --- /dev/null +++ b/tests/_common/testing_objects/mocks/mock_msal_auth.py @@ -0,0 +1,22 @@ +from microsoft_agents.authentication.msal import MsalAuth +from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration + + +class MockMsalAuth(MsalAuth): + """ + Mock object for MsalAuth + """ + + def __init__(self, mocker, client_type): + super().__init__(AgentAuthConfiguration()) + mock_client = mocker.Mock(spec=client_type) + + mock_client.acquire_token_for_client = mocker.Mock( + return_value={"access_token": "token"} + ) + mock_client.acquire_token_on_behalf_of = mocker.Mock( + return_value={"access_token": "token"} + ) + self.mock_client = mock_client + + self._create_client_application = mocker.Mock(return_value=self.mock_client) diff --git a/tests/_common/testing_objects/mocks/mock_oauth_flow.py b/tests/_common/testing_objects/mocks/mock_oauth_flow.py new file mode 100644 index 00000000..82e78328 --- /dev/null +++ b/tests/_common/testing_objects/mocks/mock_oauth_flow.py @@ -0,0 +1,60 @@ +from microsoft_agents.activity import TokenResponse +from microsoft_agents.hosting.core import OAuthFlow + +from tests._common.data import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + + +def mock_OAuthFlow( + mocker, + get_user_token_return=DEFAULTS.token, + begin_or_continue_flow_return=None, + begin_flow_return=None, + continue_flow_return=None, +): + # mock_oauth_flow_class.get_user_token = mocker.AsyncMock(return_value=token_response) + # mock_oauth_flow_class.sign_out = mocker.AsyncMock() + if isinstance(get_user_token_return, str): + get_user_token_return = TokenResponse(token=get_user_token_return) + mocker.patch.object(OAuthFlow, "get_user_token", return_value=get_user_token_return) + mocker.patch.object(OAuthFlow, "sign_out") + mocker.patch.object( + OAuthFlow, "begin_or_continue_flow", return_value=begin_or_continue_flow_return + ) + mocker.patch.object(OAuthFlow, "begin_flow", return_value=begin_flow_return) + mocker.patch.object(OAuthFlow, "continue_flow", return_value=continue_flow_return) + + +def mock_class_OAuthFlow( + mocker, + get_user_token_return=DEFAULTS.token, + begin_or_continue_flow_return=None, + begin_flow_return=None, + continue_flow_return=None, +): + mocker.patch( + "microsoft_agents.hosting.core.OAuthFlow", + new=mock_OAuthFlow( + mocker, + get_user_token_return=get_user_token_return, + begin_or_continue_flow_return=begin_or_continue_flow_return, + begin_flow_return=begin_flow_return, + continue_flow_return=continue_flow_return, + ), + ) + + +# def patch_flow( +# self, +# mocker, +# flow_response=None, +# token=None, +# ): +# mocker.patch.object( +# OAuthFlow, "get_user_token", return_value=TokenResponse(token=token) +# ) +# mocker.patch.object(OAuthFlow, "sign_out") +# mocker.patch.object( +# OAuthFlow, "begin_or_continue_flow", return_value=flow_response +# ) diff --git a/tests/_common/testing_objects/mocks/mock_user_token_client.py b/tests/_common/testing_objects/mocks/mock_user_token_client.py new file mode 100644 index 00000000..ffd8c463 --- /dev/null +++ b/tests/_common/testing_objects/mocks/mock_user_token_client.py @@ -0,0 +1,57 @@ +from microsoft_agents.activity import ( + TokenResponse, + SignInResource, +) +from microsoft_agents.hosting.core import UserTokenClient + +from tests._common.type_defs import SKIP + + +def mock_UserTokenClient( + mocker, + get_token_return=SKIP, + exchange_token_return=SKIP, + get_sign_in_resource_return=SKIP, +): + + mock_user_token_client = mocker.Mock(spec=UserTokenClient) + + if get_token_return is not SKIP: + if isinstance(get_token_return, str): + get_token_return = TokenResponse(token=get_token_return) + mock_user_token_client.user_token.get_token = mocker.AsyncMock( + return_value=get_token_return + ) + + if exchange_token_return is not SKIP: + if isinstance(exchange_token_return, str): + exchange_token_return = TokenResponse(token=exchange_token_return) + mock_user_token_client.user_token.exchange_token = mocker.AsyncMock( + return_value=exchange_token_return + ) + + if get_sign_in_resource_return is not SKIP: + mock_user_token_client.agent_sign_in.get_sign_in_resource = mocker.AsyncMock( + return_value=get_sign_in_resource_return + ) + + mock_user_token_client.user_token.sign_out = mocker.AsyncMock(return_value=None) + + return mock_user_token_client + + +def mock_class_UserTokenClient( + mocker, + get_token_return=SKIP, + exchange_token_return=SKIP, + get_sign_in_resource_return=SKIP, +): + mocker.patch( + "UserTokenClient", + new=mock_UserTokenClient( + mocker, + get_token_return=get_token_return, + exchange_token_return=exchange_token_return, + get_sign_in_resource_return=get_sign_in_resource_return, + ), + ) diff --git a/tests/hosting_core/tools/testing_utility.py b/tests/_common/testing_objects/mocks/testing_utility.py similarity index 100% rename from tests/hosting_core/tools/testing_utility.py rename to tests/_common/testing_objects/mocks/testing_utility.py diff --git a/tests/_common/testing_objects/testing_authorization.py b/tests/_common/testing_objects/testing_authorization.py new file mode 100644 index 00000000..6e962aa6 --- /dev/null +++ b/tests/_common/testing_objects/testing_authorization.py @@ -0,0 +1,89 @@ +from typing import Union + +from microsoft_agents.activity import TokenResponse + +from microsoft_agents.hosting.core import Authorization, AuthHandler, MemoryStorage + +from .testing_connection_manager import TestingConnectionManager + + +# this is broken +# keeping it around for reference +class TestingAuthorization(Authorization): + """ + Authorization system for comprehensive unit testing. + + This test double extends the Authorization class to provide a fully mocked + authorization environment suitable for testing various authentication scenarios. + It automatically configures auth handlers with mock OAuth flows that can simulate + different states like successful authentication, failed sign-in, or in-progress flows. + """ + + def __init__( + self, + mocker, + auth_handlers: dict[str, AuthHandler], + token: Union[str, None] = "default", + flow_started=False, + sign_in_failed=False, + ): + """ + Initialize the testing authorization system. + + Sets up a complete test authorization environment with memory storage, + test connection manager, and configures all provided auth handlers with + mock OAuth flows. + + Args: + auth_handlers: Dictionary mapping handler names to AuthHandler instances + token: Token value to use in mock responses. "default" uses auto-generated + tokens, None simulates no token available, or provide custom jwt token string + flow_started: Simulate OAuth flows that have already started + sign_in_failed: Simulate failed sign-in attempts + """ + # Initialize with test-friendly components + storage = MemoryStorage() + connection_manager = TestingConnectionManager() + super().__init__( + storage=storage, + auth_handlers=auth_handlers, + connection_manager=connection_manager, + service_url="a", + ) + + # Configure each auth handler with mock OAuth flow behavior + for auth_handler in self._auth_handlers.values(): + # Create default token response for this auth handler + default_token = TokenResponse( + connection_name=auth_handler.abs_oauth_connection_name, + token=f"{auth_handler.abs_oauth_connection_name}-token", + ) + + # Determine token response based on configuration + if token == "default": + token_response = default_token + elif token: + token_response = TokenResponse( + connection_name=auth_handler.abs_oauth_connection_name, + token=token, + ) + else: + token_response = None + + # Mock the OAuth flow with configurable behavior + auth_handler.flow = mocker.Mock( + get_user_token=mocker.AsyncMock(return_value=token_response), + _get_flow_state=mocker.AsyncMock( + # sign-in failed requires flow to be started + return_value=oauth_flow.FlowState( + flow_started=(flow_started or sign_in_failed) + ) + ), + begin_flow=mocker.AsyncMock(return_value=default_token), + # Mock flow continuation with optional failure simulation + continue_flow=mocker.AsyncMock( + return_value=None if sign_in_failed else default_token + ), + ) + + auth_handler.flow.flow_state = None diff --git a/tests/_common/testing_objects/testing_connection_manager.py b/tests/_common/testing_objects/testing_connection_manager.py new file mode 100644 index 00000000..5304215d --- /dev/null +++ b/tests/_common/testing_objects/testing_connection_manager.py @@ -0,0 +1,67 @@ +from microsoft_agents.hosting.core import ( + Connections, + AccessTokenProviderBase, + ClaimsIdentity, + AgentAuthConfiguration, +) + +from .testing_token_provider import TestingTokenProvider + + +class TestingConnectionManager(Connections): + """ + Connection manager for unit tests. + + This test double provides a simplified connection management interface that + returns TestingTokenProvider instances for all connection requests. It enables + testing of authorization flows without requiring actual OAuth configurations + or external authentication services. + """ + + def get_connection(self, connection_name: str) -> AccessTokenProviderBase: + """ + Get a token provider for the specified connection name. + + Args: + connection_name: Name of the OAuth connection + + Returns: + AccessTokenProviderBase: TestingTokenProvider configured with the connection name + """ + return TestingTokenProvider(connection_name) + + def get_default_connection(self) -> AccessTokenProviderBase: + """ + Get the default token provider. + + Returns: + AccessTokenProviderBase: TestingTokenProvider configured with "default" name + """ + return TestingTokenProvider("default") + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + """ + Get a token provider based on claims identity and service URL. + + In this test implementation, returns the default connection regardless + of the provided parameters. + + Args: (unused in test implementation) + claims_identity: User's claims and identity information + service_url: URL of the service requiring authentication + + Returns: + AccessTokenProviderBase: The default TestingTokenProvider + """ + return self.get_default_connection() + + def get_default_connection_configuration(self) -> AgentAuthConfiguration: + """ + Get the default authentication configuration. + + Returns: + AgentAuthConfiguration: Empty configuration suitable for testing + """ + return AgentAuthConfiguration() diff --git a/tests/_common/testing_objects/testing_custom_state.py b/tests/_common/testing_objects/testing_custom_state.py new file mode 100644 index 00000000..1c6b9f93 --- /dev/null +++ b/tests/_common/testing_objects/testing_custom_state.py @@ -0,0 +1,24 @@ +from microsoft_agents.hosting.core import AgentState, Storage, StoreItem, TurnContext + + +class TestingCustomState(AgentState): + """Custom state implementation for testing.""" + + def __init__(self, storage: Storage, namespace: str = ""): + self.namespace = namespace + super().__init__(storage, "MockCustomState") + + def get_storage_key( + self, turn_context: TurnContext, *, target_cls: type[StoreItem] = None + ) -> str: + """ + Returns the storage key for the custom state. + """ + conversation_id = turn_context.activity.conversation.id + if not conversation_id: + raise ValueError("Invalid activity: missing conversation.id") + + key = f"custom/{conversation_id}" + if self.namespace: + key = f"{self.namespace}/{key}" + return key diff --git a/tests/_common/testing_objects/testing_token_provider.py b/tests/_common/testing_objects/testing_token_provider.py new file mode 100644 index 00000000..28baffc9 --- /dev/null +++ b/tests/_common/testing_objects/testing_token_provider.py @@ -0,0 +1,57 @@ +from microsoft_agents.hosting.core import AccessTokenProviderBase + + +class TestingTokenProvider(AccessTokenProviderBase): + """ + Access token provider for unit tests. + + This test double simulates an access token provider that returns predictable + token values based on the provider name. It implements both standard token + acquisition and On-Behalf-Of (OBO) token flows for comprehensive testing + of authentication scenarios. + """ + + def __init__(self, name: str): + """ + Initialize the testing token provider. + + Args: + name: Identifier used to generate predictable token values + """ + self.name = name + + async def get_access_token( + self, resource_url: str, scopes: list[str], force_refresh: bool = False + ) -> str: + """ + Get an access token for the specified resource and scopes. + + Returns a predictable token string based on the provider name for testing. + + Args: (unused in test implementation) + resource_url: URL of the resource requiring authentication + scopes: List of OAuth scopes requested + force_refresh: Whether to force token refresh + + Returns: + str: Test token in format "{name}-token" + """ + return f"{self.name}-token" + + async def aquire_token_on_behalf_of( + self, scopes: list[str], user_assertion: str + ) -> str: + """ + Acquire a token on behalf of another user (OBO flow). + + Returns a predictable OBO token string for testing scenarios involving + delegated permissions and token exchange. + + Args: (unused in test implementation) + scopes: List of OAuth scopes requested for the OBO token + user_assertion: JWT token representing the user's identity + + Returns: + str: Test OBO token in format "{name}-obo-token" + """ + return f"{self.name}-obo-token" diff --git a/tests/hosting_core/tools/mock_user_token_client.py b/tests/_common/testing_objects/testing_user_token_client.py similarity index 96% rename from tests/hosting_core/tools/mock_user_token_client.py rename to tests/_common/testing_objects/testing_user_token_client.py index a88a81d5..e4de6357 100644 --- a/tests/hosting_core/tools/mock_user_token_client.py +++ b/tests/_common/testing_objects/testing_user_token_client.py @@ -26,8 +26,8 @@ AgentCallbackHandler = Callable[[TurnContext], Awaitable] -# patch userTokenclient constructor -class MockUserTokenClient(UserTokenClient): +# patch userTokenclient +class TestingUserTokenClient(UserTokenClient): """A mock user token client for testing.""" def __init__(self): diff --git a/tests/_common/type_defs.py b/tests/_common/type_defs.py new file mode 100644 index 00000000..bcaabe2a --- /dev/null +++ b/tests/_common/type_defs.py @@ -0,0 +1 @@ +SKIP = "SKIP" diff --git a/tests/_integration/__init__.py b/tests/_integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_integration/common/__init__.py b/tests/_integration/common/__init__.py new file mode 100644 index 00000000..e70f4096 --- /dev/null +++ b/tests/_integration/common/__init__.py @@ -0,0 +1,6 @@ +from .testing_environment import TestingEnvironment, MockTestingEnvironment + +__all__ = [ + "TestingEnvironment", + "MockTestingEnvironment", +] diff --git a/tests/_integration/common/testing_environment.py b/tests/_integration/common/testing_environment.py new file mode 100644 index 00000000..7be1a0f1 --- /dev/null +++ b/tests/_integration/common/testing_environment.py @@ -0,0 +1,75 @@ +import pytest + +from microsoft_agents.activity import load_configuration_from_env + +from microsoft_agents.hosting.core import ( + AgentApplication, + Storage, + MemoryStorage, + Authorization, + ChannelAdapter, + TurnState, + Connections, +) + +from tests._common.testing_objects import ( + TestingConnectionManager, + TestingAdapter, +) + + +class TestingEnvironment: + agent_app: AgentApplication + storage: Storage + connection_manager: Connections + adapter: ChannelAdapter + authorization: Authorization + env: dict + + def __init__(self, mocker): + self._mocker = mocker + self.env = self.get_env() + + def get_env(self): + return {} + + +# class SampleEnvironment(TestingEnvironment): + +# def setup_method(self): +# super().setup_method() + +# self.agents_sdk_config = load_configuration_from_env(self.env) + +# # Create storage and connection manager +# self.storage = MemoryStorage() +# self.connection_manager = MsalConnectionManager(**agents_sdk_config) +# self.adapter = CloudAdapter(connection_manager=self.connection_manager) +# self.authorization = Authorization(self.storage, self.connection_manager, **agents_sdk_config) + +# self.agent_app = AgentApplication[TurnState]( +# storage=self.storage, adapter=self.adapter, authorization=self.authorization, **agents_sdk_config +# ) + + +class MockTestingEnvironment(TestingEnvironment): + def __init__(self, mocker): + super().__init__(mocker) + + env_dict = self.get_env() + + agents_sdk_config = load_configuration_from_env(env_dict) + + self.storage = MemoryStorage() + self.connection_manager = TestingConnectionManager() + self.adapter = TestingAdapter() + self.authorization = Authorization( + self.storage, self.connection_manager, **agents_sdk_config + ) + + self.agent_app = AgentApplication[TurnState]( + storage=self.storage, + adapter=self.adapter, + authorization=self.authorization, + **agents_sdk_config + ) diff --git a/tests/_integration/scenarios/__init__.py b/tests/_integration/scenarios/__init__.py new file mode 100644 index 00000000..ecdb43a2 --- /dev/null +++ b/tests/_integration/scenarios/__init__.py @@ -0,0 +1,3 @@ +from .quickstart import main as QuickstartSample + +__all__ = ["QuickstartSample"] diff --git a/tests/_integration/scenarios/quickstart.py b/tests/_integration/scenarios/quickstart.py new file mode 100644 index 00000000..413d1cac --- /dev/null +++ b/tests/_integration/scenarios/quickstart.py @@ -0,0 +1,34 @@ +import pytest +import re + +from microsoft_agents.hosting.core import TurnContext, TurnState + +from tests._integration.common import TestingEnvironment + +ON_MEMBERS_ADDED_MESSAGE = "Hello and welcome!" +HELLO_MESSAGE = "Hello!" +ON_MESSAGE_TEMPLATE = "You said: {text}" +ON_ERROR_MESSAGE = "Oops. Something went wrong." + + +def main(testenv: TestingEnvironment): + + AGENT_APP = testenv.agent_app + + @AGENT_APP.conversation_update("membersAdded") + async def on_members_added(context: TurnContext, state: TurnState): + await context.send_activity(ON_MEMBERS_ADDED_MESSAGE) + + @AGENT_APP.message(re.compile(r"^hello$")) + async def on_hello(context: TurnContext, state: TurnState): + await context.send_activity(HELLO_MESSAGE) + + @AGENT_APP.activity("message") + async def on_message(context: TurnContext, state: TurnState): + await context.send_activity( + ON_MESSAGE_TEMPLATE.format(text=context.activity.text) + ) + + @AGENT_APP.error + async def on_error(context: TurnContext, error: Exception): + await context.send_activity(ON_ERROR_MESSAGE) diff --git a/tests/_integration/test_quickstart.py b/tests/_integration/test_quickstart.py new file mode 100644 index 00000000..32fac1bf --- /dev/null +++ b/tests/_integration/test_quickstart.py @@ -0,0 +1,37 @@ +import pytest + +from tests._integration.common.testing_environment import ( + TestingEnvironment, + MockTestingEnvironment, +) +from tests._integration.scenarios.quickstart import main + + +class _TestQuickstart: + @pytest.fixture + def testenv(self, mocker) -> TestingEnvironment: + raise NotImplementedError() + + # @pytest.fixture + # def client(self, testenv) -> TestClient: + # return TestClient(testenv.adapter) + + @pytest.mark.asyncio + async def test_quickstart(self, testenv): + main(testenv) + # testenv.adapter.send_activity("Hello World") + + +# class TestQuickstartMultipleEnvs(_TestQuickstart): + +# @pytest.fixture( +# params=[MockTestingEnvironment, SampleEnvironment], +# ) +# def testenv(self, mocker, request) -> TestingEnvironment: +# return request.param(mocker) + + +class TestQuickstartMockEnv(_TestQuickstart): + @pytest.fixture + def testenv(self, mocker) -> TestingEnvironment: + return MockTestingEnvironment(mocker) diff --git a/tests/activity/_common/__init__.py b/tests/activity/_common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/activity/tools/testing_model_utils.py b/tests/activity/_common/model_utils.py similarity index 73% rename from tests/activity/tools/testing_model_utils.py rename to tests/activity/_common/model_utils.py index 17a23944..6b57b1ff 100644 --- a/tests/activity/tools/testing_model_utils.py +++ b/tests/activity/_common/model_utils.py @@ -1,15 +1,7 @@ -from abc import ABC -from typing import Any, Callable - -from microsoft_agents.activity import ( - AgentsModel, - Activity, -) +from microsoft_agents.activity import AgentsModel from microsoft_agents.activity._model_utils import ( ModelFieldHelper, SkipIf, - pick_model_dict, - pick_model, ) @@ -24,12 +16,12 @@ def __init__(self, value): class PickField(ModelFieldHelper): - def __init__(self, original, key_in_original=None): + def __init__(self, original: AgentsModel, key_in_original=None): assert isinstance(original, AgentsModel) self.original = original self.key_in_original = key_in_original - def process(self, key): + def process(self, key: str): target_key = self.key_in_original or key diff --git a/tests/activity/data/activity_test_data.py b/tests/activity/_common/my_channel_data.py similarity index 73% rename from tests/activity/data/activity_test_data.py rename to tests/activity/_common/my_channel_data.py index 65e49560..ca3050af 100644 --- a/tests/activity/data/activity_test_data.py +++ b/tests/activity/_common/my_channel_data.py @@ -1,12 +1,4 @@ -from microsoft_agents.activity import ( - Activity, - Attachment, - Mention, - GeoCoordinates, - Place, - Thing, - Entity, -) +from microsoft_agents.activity import Activity, Attachment class MyChannelData: diff --git a/tests/activity/tools/testing_activity.py b/tests/activity/_common/testing_activity.py similarity index 100% rename from tests/activity/tools/testing_activity.py rename to tests/activity/_common/testing_activity.py diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index 44f42f4b..179b40df 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -18,8 +18,8 @@ Thing, ) -from .data.activity_test_data import MyChannelData -from .tools.testing_activity import create_test_activity +from tests.activity._common.my_channel_data import MyChannelData +from tests.activity._common.testing_activity import create_test_activity def helper_validate_recipient_and_from( @@ -45,7 +45,6 @@ def helper_get_expected_try_get_channel_data_result(channel_data) -> bool: class TestActivityConversationOps: - @pytest.fixture def activity(self): return create_test_activity("en-us") diff --git a/tests/activity/test_tools.py b/tests/activity/test_tools.py index f50b88e5..e33edf89 100644 --- a/tests/activity/test_tools.py +++ b/tests/activity/test_tools.py @@ -9,11 +9,10 @@ pick_model_dict, ) -from .tools.testing_model_utils import SkipFalse, SkipEmpty, PickField +from tests.activity._common.model_utils import SkipFalse, SkipEmpty, PickField class TestModelUtils: - def test_skip_if(self): field = SkipIf("foo", lambda v: v == "foo") assert field.process("key") == {} diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 5edf24d1..21576a81 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -1,29 +1,9 @@ -import unittest -from unittest.mock import Mock import pytest from msal import ManagedIdentityClient, ConfidentialClientApplication -from microsoft_agents.authentication.msal import MsalAuth -from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration +from microsoft_agents.hosting.core import Connections -class TestingMsalAuth(MsalAuth): - """ - Mock object for MsalAuth - """ - - def __init__(self, client_type): - super().__init__(AgentAuthConfiguration()) - mock_client = Mock(spec=client_type) - - mock_client.acquire_token_for_client = Mock( - return_value={"access_token": "token"} - ) - mock_client.acquire_token_on_behalf_of = Mock( - return_value={"access_token": "token"} - ) - self.mock_client = mock_client - - self._create_client_application = Mock(return_value=self.mock_client) +from tests._common.testing_objects import MockMsalAuth class TestMsalAuth: @@ -32,8 +12,8 @@ class TestMsalAuth: """ @pytest.mark.asyncio - async def test_get_access_token_managed_identity(self): - mock_auth = TestingMsalAuth(ManagedIdentityClient) + async def test_get_access_token_managed_identity(self, mocker): + mock_auth = MockMsalAuth(mocker, ManagedIdentityClient) token = await mock_auth.get_access_token( "https://test.api.botframework.com", scopes=["test-scope"] ) @@ -44,8 +24,8 @@ async def test_get_access_token_managed_identity(self): ) @pytest.mark.asyncio - async def test_get_access_token_confidential(self): - mock_auth = TestingMsalAuth(ConfidentialClientApplication) + async def test_get_access_token_confidential(self, mocker): + mock_auth = MockMsalAuth(mocker, ConfidentialClientApplication) token = await mock_auth.get_access_token( "https://test.api.botframework.com", scopes=["test-scope"] ) @@ -56,8 +36,8 @@ async def test_get_access_token_confidential(self): ) @pytest.mark.asyncio - async def test_aquire_token_on_behalf_of_managed_identity(self): - mock_auth = TestingMsalAuth(ManagedIdentityClient) + async def test_aquire_token_on_behalf_of_managed_identity(self, mocker): + mock_auth = MockMsalAuth(mocker, ManagedIdentityClient) try: await mock_auth.aquire_token_on_behalf_of( @@ -69,9 +49,11 @@ async def test_aquire_token_on_behalf_of_managed_identity(self): assert False @pytest.mark.asyncio - async def test_aquire_token_on_behalf_of_confidential(self): - mock_auth = TestingMsalAuth(ConfidentialClientApplication) - mock_auth._create_client_application = Mock(return_value=mock_auth.mock_client) + async def test_aquire_token_on_behalf_of_confidential(self, mocker): + mock_auth = MockMsalAuth(mocker, ConfidentialClientApplication) + mock_auth._create_client_application = mocker.Mock( + return_value=mock_auth.mock_client + ) token = await mock_auth.aquire_token_on_behalf_of( scopes=["test-scope"], user_assertion="test-assertion" diff --git a/tests/hosting_core/_common/__init__.py b/tests/hosting_core/_common/__init__.py new file mode 100644 index 00000000..3a267823 --- /dev/null +++ b/tests/hosting_core/_common/__init__.py @@ -0,0 +1,3 @@ +from .flow_state_eq import flow_state_eq + +__all__ = ["flow_state_eq"] diff --git a/tests/hosting_core/_common/flow_state_eq.py b/tests/hosting_core/_common/flow_state_eq.py new file mode 100644 index 00000000..fea6585c --- /dev/null +++ b/tests/hosting_core/_common/flow_state_eq.py @@ -0,0 +1,29 @@ +from typing import Optional + +from microsoft_agents.hosting.core import FlowState + +from tests._common import approx_eq + + +# 100 ms tolerance +def flow_state_eq( + fs1: Optional[FlowState], fs2: Optional[FlowState], tol: float = 0.1 +) -> bool: + + if fs1 is None and fs2 is None: + return True + elif fs1 is None or fs2 is None: + return False + + eq = False + + if approx_eq(fs1.expiration, fs2.expiration, tol=tol): + old_exp1 = fs1.expiration + old_exp2 = fs2.expiration + + eq = fs1 == fs2 + + fs1.expiration = old_exp1 + fs2.expiration = old_exp2 + + return eq diff --git a/tests/hosting_core/app/__init__.py b/tests/hosting_core/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/test_authorization.py b/tests/hosting_core/app/test_authorization.py similarity index 51% rename from tests/hosting_core/test_authorization.py rename to tests/hosting_core/app/test_authorization.py index e2104cd5..effacf87 100644 --- a/tests/hosting_core/test_authorization.py +++ b/tests/hosting_core/app/test_authorization.py @@ -1,171 +1,99 @@ import pytest - +from datetime import datetime import jwt from microsoft_agents.activity import ActivityTypes, TokenResponse -from microsoft_agents.hosting.core import MemoryStorage -from microsoft_agents.hosting.core.connector.user_token_base import UserTokenBase -from microsoft_agents.hosting.core.connector.user_token_client_base import ( - UserTokenClientBase, -) -from microsoft_agents.hosting.core.app.oauth import Authorization -from microsoft_agents.hosting.core.oauth import ( +from microsoft_agents.hosting.core import ( FlowStorageClient, FlowErrorTag, FlowStateTag, + FlowState, FlowResponse, OAuthFlow, + Authorization, + MemoryStorage, ) from tests._common.storage.utils import StorageBaseline # test constants -from .tools.testing_oauth import * -from .tools.testing_authorization import ( - TestingConnectionManager as MockConnectionManager, +from tests._common.data import ( + TEST_FLOW_DATA, + TEST_AUTH_DATA, + TEST_STORAGE_DATA, + TEST_DEFAULTS, create_test_auth_handler, ) - - -class TestUtils: - - def create_context( - self, - mocker, - channel_id="__channel_id", - user_id="__user_id", - user_token_client=None, - ): - - if not user_token_client: - user_token_client = self.create_mock_user_token_client(mocker) - - turn_context = mocker.Mock() - turn_context.activity.channel_id = channel_id - turn_context.activity.from_property.id = user_id - turn_context.activity.type = ActivityTypes.message - turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" - agent_identity = mocker.Mock() - agent_identity.claims = {"aud": MS_APP_ID} - turn_context.turn_state = { - "__user_token_client": user_token_client, - "__agent_identity_key": agent_identity, - } - return turn_context - - def create_mock_user_token_client( - self, - mocker, - token=RES_TOKEN, - ): - mock_user_token_client_class = mocker.Mock(spec=UserTokenClientBase) - mock_user_token_client_class.user_token = mocker.Mock(spec=UserTokenBase) - mock_user_token_client_class.user_token.get_token = mocker.AsyncMock( - return_value=TokenResponse() if not token else TokenResponse(token=token) - ) - mock_user_token_client_class.user_token.sign_out = mocker.AsyncMock() - return mock_user_token_client_class +from tests._common.fixtures import FlowStateFixtures +from tests._common.testing_objects import ( + TestingConnectionManager as MockConnectionManager, + mock_class_OAuthFlow, + mock_UserTokenClient, +) +from tests.hosting_core._common import flow_state_eq + +DEFAULTS = TEST_DEFAULTS() +FLOW_DATA = TEST_FLOW_DATA() +STORAGE_DATA = TEST_STORAGE_DATA() + + +def testing_TurnContext( + mocker, + channel_id=DEFAULTS.channel_id, + user_id=DEFAULTS.user_id, + user_token_client=None, +): + if not user_token_client: + user_token_client = mock_UserTokenClient(mocker) + + turn_context = mocker.Mock() + turn_context.activity.channel_id = channel_id + turn_context.activity.from_property.id = user_id + turn_context.activity.type = ActivityTypes.message + turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" + turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" + agent_identity = mocker.Mock() + agent_identity.claims = {"aud": DEFAULTS.ms_app_id} + turn_context.turn_state = { + "__user_token_client": user_token_client, + "__agent_identity_key": agent_identity, + } + return turn_context + + +class TestEnv(FlowStateFixtures): + def setup_method(self): + self.TurnContext = testing_TurnContext + self.UserTokenClient = mock_UserTokenClient + self.ConnectionManager = lambda mocker: MockConnectionManager() @pytest.fixture - def mock_user_token_client_class(self, mocker): - return self.create_mock_user_token_client(mocker) - - def create_mock_oauth_flow_class(self, mocker, token_response): - mock_oauth_flow_class = mocker.Mock(spec=OAuthFlow) - # mock_oauth_flow_class.get_user_token = mocker.AsyncMock(return_value=token_response) - # mock_oauth_flow_class.sign_out = mocker.AsyncMock() - mocker.patch.object(OAuthFlow, "get_user_token", return_value=token_response) - mocker.patch.object(OAuthFlow, "sign_out") - return mock_oauth_flow_class + def turn_context(self, mocker): + return self.TurnContext(mocker) @pytest.fixture - def mock_oauth_flow_class(self, mocker): - return self.create_mock_oauth_flow_class(mocker, TokenResponse(token=RES_TOKEN)) - # mock_flow_class = mocker.Mock(spec=OAuthFlow) - - # # mocker.patch.object(OAuthFlow, "__init__", return_value=mock_flow_class) - # mock_flow_class.get_user_token = mocker.AsyncMock(return_value=TokenResponse(token=RES_TOKEN)) - # mock_flow_class.sign_out = mocker.AsyncMock() - # mocker.patch.object(OAuthFlow, "get_user_token") - - # return mock_flow_class + def baseline_storage(self): + return StorageBaseline(TEST_STORAGE_DATA().dict) @pytest.fixture - def turn_context(self, mocker): - return self.create_context(mocker, "__channel_id", "__user_id", "__connection") - - def create_user_token_client(self, mocker, get_token_return=""): - - user_token_client = mocker.Mock(spec=UserTokenClientBase) - user_token_client.user_token = mocker.Mock(spec=UserTokenBase) - user_token_client.user_token.get_token = mocker.AsyncMock() - user_token_client.user_token.sign_out = mocker.AsyncMock() - - return_value = TokenResponse() - if isinstance(get_token_return, TokenResponse): - return_value = get_token_return - elif get_token_return: - return_value = TokenResponse(token=get_token_return) - user_token_client.user_token.get_token.return_value = return_value - - return user_token_client + def storage(self): + return MemoryStorage(STORAGE_DATA.get_init_data()) @pytest.fixture - def user_token_client(self, mocker): - return self.create_user_token_client(mocker, get_token_return=RES_TOKEN) + def connection_manager(self, mocker): + return self.ConnectionManager(mocker) @pytest.fixture def auth_handlers(self): - handlers = {} - for key in STORAGE_INIT_DATA().keys(): - if key.startswith("auth/"): - auth_handler_name = key[key.rindex("/") + 1 :] - handlers[auth_handler_name] = create_test_auth_handler( - auth_handler_name, True - ) - return handlers + return TEST_AUTH_DATA().auth_handlers @pytest.fixture - def connection_manager(self): - return MockConnectionManager() - - @pytest.fixture - def auth(self, connection_manager, storage, auth_handlers): + def authorization(self, connection_manager, storage, auth_handlers): return Authorization(storage, connection_manager, auth_handlers) -class TestAuthorizationUtils(TestUtils): - - def create_storage(self): - return MemoryStorage(STORAGE_INIT_DATA()) - - @pytest.fixture - def storage(self): - return self.create_storage() - - @pytest.fixture - def baseline_storage(self): - return StorageBaseline(STORAGE_INIT_DATA()) - - def patch_flow( - self, - mocker, - flow_response=None, - token=None, - ): - mocker.patch.object( - OAuthFlow, "get_user_token", return_value=TokenResponse(token=token) - ) - mocker.patch.object(OAuthFlow, "sign_out") - mocker.patch.object( - OAuthFlow, "begin_or_continue_flow", return_value=flow_response - ) - - -class TestAuthorization(TestAuthorizationUtils): - +class TestAuthorization(TestEnv): def test_init_configuration_variants( self, storage, connection_manager, auth_handlers ): @@ -215,12 +143,12 @@ def test_init_configuration_variants( [["missing", "webchat", "Alice"], ["handler", "teams", "Bob"]], ) async def test_open_flow_value_error( - self, mocker, auth, auth_handler_id, channel_id, user_id + self, mocker, authorization, auth_handler_id, channel_id, user_id ): """Test opening a flow with a missing auth handler.""" - context = self.create_context(mocker, channel_id, user_id) + context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) with pytest.raises(ValueError): - async with auth.open_flow(context, auth_handler_id): + async with authorization.open_flow(context, auth_handler_id): pass @pytest.mark.asyncio @@ -244,7 +172,7 @@ async def test_open_flow_readonly( ): """Test opening a flow and not modifying it.""" # setup - context = self.create_context(mocker, channel_id, user_id) + context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) auth = Authorization(storage, connection_manager, auth_handlers) flow_storage_client = FlowStorageClient(channel_id, user_id, storage) @@ -264,17 +192,24 @@ async def test_open_flow_success_modified_complete_flow( mocker, storage, connection_manager, - mock_user_token_client_class, auth_handlers, ): - # setup + # mock channel_id = "teams" user_id = "Alice" auth_handler_id = "graph" - self.create_user_token_client(mocker, get_token_return=RES_TOKEN) + user_token_client = self.UserTokenClient( + mocker, get_token_return=DEFAULTS.token + ) + context = self.TurnContext( + mocker, + channel_id=channel_id, + user_id=user_id, + user_token_client=user_token_client, + ) - context = self.create_context(mocker, channel_id, user_id) + # setup context.activity.type = ActivityTypes.message context.activity.text = "123456" @@ -285,19 +220,16 @@ async def test_open_flow_success_modified_complete_flow( async with auth.open_flow(context, auth_handler_id) as flow: expected_flow_state = flow.flow_state expected_flow_state.tag = FlowStateTag.COMPLETE - expected_flow_state.user_token = RES_TOKEN + expected_flow_state.user_token = DEFAULTS.token flow_response = await flow.begin_or_continue_flow(context.activity) res_flow_state = flow_response.flow_state # verify actual_flow_state = await flow_storage_client.read(auth_handler_id) - expected_flow_state.expiration = ( - res_flow_state.expiration - ) # we won't check this for now - - assert res_flow_state == expected_flow_state - assert actual_flow_state == expected_flow_state + expected_flow_state.expiration = actual_flow_state.expiration + assert flow_state_eq(actual_flow_state, expected_flow_state) + assert flow_state_eq(res_flow_state, expected_flow_state) @pytest.mark.asyncio async def test_open_flow_success_modified_failure( @@ -312,7 +244,7 @@ async def test_open_flow_success_modified_failure( user_id = "Bob" auth_handler_id = "slack" - context = self.create_context(mocker, channel_id, user_id) + context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) context.activity.text = "invalid_magic_code" auth = Authorization(storage, connection_manager, auth_handlers) @@ -329,13 +261,10 @@ async def test_open_flow_success_modified_failure( # verify actual_flow_state = await flow_storage_client.read(auth_handler_id) - expected_flow_state.expiration = ( - actual_flow_state.expiration - ) # we won't check this for now assert flow_response.flow_error_tag == FlowErrorTag.MAGIC_FORMAT - assert res_flow_state == expected_flow_state - assert actual_flow_state == expected_flow_state + assert flow_state_eq(res_flow_state, expected_flow_state) + assert flow_state_eq(actual_flow_state, expected_flow_state) @pytest.mark.asyncio async def test_open_flow_success_modified_signout( @@ -346,7 +275,7 @@ async def test_open_flow_success_modified_signout( user_id = "Alice" auth_handler_id = "graph" - context = self.create_context(mocker, channel_id, user_id) + context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) auth = Authorization(storage, connection_manager, auth_handlers) flow_storage_client = FlowStorageClient(channel_id, user_id, storage) @@ -361,38 +290,35 @@ async def test_open_flow_success_modified_signout( # verify actual_flow_state = await flow_storage_client.read(auth_handler_id) - expected_flow_state.expiration = ( - actual_flow_state.expiration - ) # we won't check this for now - assert actual_flow_state == expected_flow_state + assert flow_state_eq(actual_flow_state, expected_flow_state) @pytest.mark.asyncio - async def test_get_token_success(self, mocker, auth): - mock_user_token_client_class = self.create_user_token_client( - mocker, get_token_return=TokenResponse(token="token") - ) - context = self.create_context( + async def test_get_token_success(self, mocker, authorization): + user_token_client = self.UserTokenClient(mocker, get_token_return="token") + context = self.TurnContext( mocker, - "__channel_id", - "__user_id", - user_token_client=mock_user_token_client_class, + channel_id="__channel_id", + user_id="__user_id", + user_token_client=user_token_client, + ) + assert await authorization.get_token(context, "slack") == TokenResponse( + token="token" ) - assert await auth.get_token(context, "slack") == TokenResponse(token="token") - mock_user_token_client_class.user_token.get_token.assert_called_once() + user_token_client.user_token.get_token.assert_called_once() @pytest.mark.asyncio - async def test_get_token_empty_response(self, mocker, auth): - mock_user_token_client_class = self.create_user_token_client( + async def test_get_token_empty_response(self, mocker, authorization): + user_token_client = self.UserTokenClient( mocker, get_token_return=TokenResponse() ) - context = self.create_context( + context = self.TurnContext( mocker, - "__channel_id", - "__user_id", - user_token_client=mock_user_token_client_class, + channel_id="__channel_id", + user_id="__user_id", + user_token_client=user_token_client, ) - assert await auth.get_token(context, "graph") == TokenResponse() - mock_user_token_client_class.user_token.get_token.assert_called_once() + assert await authorization.get_token(context, "graph") == TokenResponse() + user_token_client.user_token.get_token.assert_called_once() @pytest.mark.asyncio async def test_get_token_error( @@ -400,71 +326,72 @@ async def test_get_token_error( ): auth = Authorization(storage, connection_manager, auth_handlers) with pytest.raises(ValueError): - await auth.get_token(turn_context, "missing-handler") + await auth.get_token( + turn_context, DEFAULTS.missing_abs_oauth_connection_name + ) @pytest.mark.asyncio - async def test_exchange_token_no_token(self, mocker, turn_context, auth): - self.create_mock_oauth_flow_class(mocker, TokenResponse()) - res = await auth.exchange_token(turn_context, ["scope"], "github") + async def test_exchange_token_no_token(self, mocker, turn_context, authorization): + mock_class_OAuthFlow(mocker, get_user_token_return=TokenResponse()) + res = await authorization.exchange_token(turn_context, ["scope"], "github") assert res == TokenResponse() @pytest.mark.asyncio - async def test_exchange_token_not_exchangeable(self, mocker, turn_context, auth): + async def test_exchange_token_not_exchangeable( + self, mocker, turn_context, authorization + ): token = jwt.encode({"aud": "invalid://botframework.test.api"}, "") - self.create_mock_oauth_flow_class( - mocker, TokenResponse(connection_name="github", token=token) + mock_class_OAuthFlow( + mocker, + get_user_token_return=TokenResponse(connection_name="github", token=token), ) - res = await auth.exchange_token(turn_context, ["scope"], "github") + res = await authorization.exchange_token(turn_context, ["scope"], "github") assert res == TokenResponse() @pytest.mark.asyncio - async def test_exchange_token_valid_exchangeable(self, turn_context, mocker, auth): + async def test_exchange_token_valid_exchangeable(self, mocker, authorization): + # setup token = jwt.encode({"aud": "api://botframework.test.api"}, "") - self.create_mock_oauth_flow_class( - mocker, TokenResponse(connection_name="github", token=token) - ) - mock_user_token_client_class = self.create_mock_user_token_client( - mocker, token=token + mock_class_OAuthFlow( + mocker, + get_user_token_return=TokenResponse(connection_name="github", token=token), ) - mock_user_token_client_class.user_token.exchange_token = mocker.AsyncMock( - return_value=TokenResponse( - scopes=["scope"], token=token, connection_name="github" - ) + user_token_client = self.UserTokenClient( + mocker, get_token_return="github-obo-connection-obo-token" ) - res = await auth.exchange_token(turn_context, ["scope"], "github") + turn_context = self.TurnContext(mocker, user_token_client=user_token_client) + # test + res = await authorization.exchange_token(turn_context, ["scope"], "github") assert res == TokenResponse(token="github-obo-connection-obo-token") @pytest.mark.asyncio - async def test_get_active_flow_state(self, mocker, auth): - context = self.create_context(mocker, "webchat", "Alice") - actual_flow_state = await auth.get_active_flow_state(context) - assert ( - actual_flow_state - == STORAGE_SAMPLE_DICT[flow_key("webchat", "Alice", "github")] - ) + async def test_get_active_flow_state(self, mocker, authorization): + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") + actual_flow_state = await authorization.get_active_flow_state(context) + assert actual_flow_state == STORAGE_DATA.dict["auth/webchat/Alice/github"] @pytest.mark.asyncio - async def test_get_active_flow_state_missing(self, mocker, auth): - context = self.create_context(mocker, "__channel_id", "__user_id") - res = await auth.get_active_flow_state(context) + async def test_get_active_flow_state_missing(self, mocker, authorization): + context = self.TurnContext( + mocker, channel_id="__channel_id", user_id="__user_id" + ) + res = await authorization.get_active_flow_state(context) assert res is None @pytest.mark.asyncio - async def test_begin_or_continue_flow_success(self, mocker, auth): + async def test_begin_or_continue_flow_success(self, mocker, authorization): # robrandao: TODO -> lower priority -> more testing here # setup - mocker.patch.object( - OAuthFlow, - "begin_or_continue_flow", - return_value=FlowResponse( + mock_class_OAuthFlow( + mocker, + begin_or_continue_flow_return=FlowResponse( token_response=TokenResponse(token="token"), flow_state=FlowState( tag=FlowStateTag.COMPLETE, auth_handler_id="github" ), ), ) - context = self.create_context(mocker, "webchat", "Alice") - + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") context.dummy_val = None def on_sign_in_success(context, turn_state, auth_handler_id): @@ -474,17 +401,21 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): context.dummy_val = str(err) # test - auth.on_sign_in_success(on_sign_in_success) - auth.on_sign_in_failure(on_sign_in_failure) - flow_response = await auth.begin_or_continue_flow(context, None, "github") + authorization.on_sign_in_success(on_sign_in_success) + authorization.on_sign_in_failure(on_sign_in_failure) + flow_response = await authorization.begin_or_continue_flow( + context, None, "github" + ) assert context.dummy_val == "github" assert flow_response.token_response == TokenResponse(token="token") @pytest.mark.asyncio - async def test_begin_or_continue_flow_already_completed(self, mocker, auth): + async def test_begin_or_continue_flow_already_completed( + self, mocker, authorization + ): # robrandao: TODO -> lower priority -> more testing here # setup - context = self.create_context(mocker, "webchat", "Alice") + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") context.dummy_val = None @@ -495,32 +426,30 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): context.dummy_val = str(err) # test - auth.on_sign_in_success(on_sign_in_success) - auth.on_sign_in_failure(on_sign_in_failure) - flow_response = await auth.begin_or_continue_flow(context, None, "graph") + authorization.on_sign_in_success(on_sign_in_success) + authorization.on_sign_in_failure(on_sign_in_failure) + flow_response = await authorization.begin_or_continue_flow( + context, None, "graph" + ) assert context.dummy_val == None assert flow_response.token_response == TokenResponse(token="test_token") assert flow_response.continuation_activity is None @pytest.mark.asyncio - async def test_begin_or_continue_flow_failure( - self, mocker, mock_oauth_flow_class, auth - ): + async def test_begin_or_continue_flow_failure(self, mocker, authorization): # robrandao: TODO -> lower priority -> more testing here # setup - mocker.patch.object( - OAuthFlow, - "begin_or_continue_flow", - return_value=FlowResponse( + mock_class_OAuthFlow( + mocker, + begin_or_continue_flow_return=FlowResponse( token_response=TokenResponse(token="token"), flow_state=FlowState( tag=FlowStateTag.FAILURE, auth_handler_id="github" ), - flow_state_error=FlowErrorTag.MAGIC_FORMAT, + flow_error_tag=FlowErrorTag.MAGIC_FORMAT, ), ) - context = self.create_context(mocker, "webchat", "Alice") - + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") context.dummy_val = None def on_sign_in_success(context, turn_state, auth_handler_id): @@ -530,37 +459,42 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): context.dummy_val = str(err) # test - auth.on_sign_in_success(on_sign_in_success) - auth.on_sign_in_failure(on_sign_in_failure) - flow_response = await auth.begin_or_continue_flow(context, None, "github") - assert context.dummy_val == "FlowErrorTag.NONE" + authorization.on_sign_in_success(on_sign_in_success) + authorization.on_sign_in_failure(on_sign_in_failure) + flow_response = await authorization.begin_or_continue_flow( + context, None, "github" + ) + assert context.dummy_val == "FlowErrorTag.MAGIC_FORMAT" assert flow_response.token_response == TokenResponse(token="token") @pytest.mark.parametrize("auth_handler_id", ["graph", "github"]) - def test_resolve_handler_specified(self, auth, auth_handlers, auth_handler_id): - assert auth.resolve_handler(auth_handler_id) == auth_handlers[auth_handler_id] + def test_resolve_handler_specified( + self, authorization, auth_handlers, auth_handler_id + ): + assert ( + authorization.resolve_handler(auth_handler_id) + == auth_handlers[auth_handler_id] + ) - def test_resolve_handler_error(self, auth): + def test_resolve_handler_error(self, authorization): with pytest.raises(ValueError): - auth.resolve_handler("missing-handler") + authorization.resolve_handler("missing-handler") - def test_resolve_handler_first(self, auth, auth_handlers): - assert auth.resolve_handler() == next(iter(auth_handlers.values())) + def test_resolve_handler_first(self, authorization, auth_handlers): + assert authorization.resolve_handler() == next(iter(auth_handlers.values())) @pytest.mark.asyncio async def test_sign_out_individual( self, mocker, - mock_user_token_client_class, - mock_oauth_flow_class, storage, - baseline_storage, connection_manager, auth_handlers, ): # setup + mock_class_OAuthFlow(mocker) storage_client = FlowStorageClient("teams", "Alice", storage) - context = self.create_context(mocker, "teams", "Alice") + context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") auth = Authorization(storage, connection_manager, auth_handlers) # test @@ -577,19 +511,17 @@ async def test_sign_out_individual( async def test_sign_out_all( self, mocker, - mock_user_token_client_class, - mock_oauth_flow_class, - turn_context, storage, - baseline_storage, connection_manager, auth_handlers, ): # setup + mock_class_OAuthFlow(mocker) + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") storage_client = FlowStorageClient("webchat", "Alice", storage) - auth = Authorization(storage, connection_manager, auth_handlers) - context = self.create_context(mocker, "webchat", "Alice") + + # test await auth.sign_out(context) # verify diff --git a/tests/hosting_core/test_state.py b/tests/hosting_core/app/test_state.py similarity index 83% rename from tests/hosting_core/test_state.py rename to tests/hosting_core/app/test_state.py index 02d5ec6c..fc5915bb 100644 --- a/tests/hosting_core/test_state.py +++ b/tests/hosting_core/app/test_state.py @@ -5,7 +5,6 @@ from typing import Any, Dict import pytest -from unittest.mock import AsyncMock, MagicMock from microsoft_agents.hosting.core.app.state.state import ( State, @@ -15,17 +14,7 @@ from microsoft_agents.hosting.core.turn_context import TurnContext from microsoft_agents.hosting.core.storage import Storage, StoreItem - -class MockStoreItem(StoreItem): - def __init__(self, data=None): - self.data = data or {} - - def store_item_to_json(self): - return self.data - - @staticmethod - def from_json_to_store_item(json_data): - return MockStoreItem(json_data) +from tests._common.storage import MockStoreItem @state @@ -124,20 +113,20 @@ def test_create_property(self): assert accessor._state == test_state @pytest.mark.asyncio - async def test_save_with_no_storage(self): + async def test_save_with_no_storage(self, mocker): """Test that save does nothing when no storage is provided.""" test_state = StateForTesting() - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) # Should not raise any exceptions await test_state.save(context) @pytest.mark.asyncio - async def test_save_with_empty_key(self): + async def test_save_with_empty_key(self, mocker): """Test that save does nothing when key is empty.""" test_state = StateForTesting() - context = MagicMock(spec=TurnContext) - storage = MagicMock(spec=Storage) + context = mocker.MagicMock(spec=TurnContext) + storage = mocker.MagicMock(spec=Storage) # Should not call storage methods await test_state.save(context, storage) @@ -146,18 +135,18 @@ async def test_save_with_empty_key(self): storage.write.assert_not_called() @pytest.mark.asyncio - async def test_save_with_storage(self): + async def test_save_with_storage(self, mocker): """Test saving state to storage.""" - test_state = await StateForTesting.load(MagicMock()) + test_state = await StateForTesting.load(mocker.MagicMock()) test_state.test_property = "new_value" # Add item to deleted list test_state.__deleted__ = ["deleted-key"] - context = MagicMock(spec=TurnContext) - storage = MagicMock(spec=Storage) - storage.delete = AsyncMock() - storage.write = AsyncMock() + context = mocker.MagicMock(spec=TurnContext) + storage = mocker.MagicMock(spec=Storage) + storage.delete = mocker.AsyncMock() + storage.write = mocker.AsyncMock() await test_state.save(context, storage) @@ -179,19 +168,19 @@ async def test_save_with_storage(self): assert test_state.__deleted__ == [] @pytest.mark.asyncio - async def test_load_from_storage(self): + async def test_load_from_storage(self, mocker): """Test loading state from storage.""" - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) context.activity.conversation.id = "test-conversation" - storage = MagicMock(spec=Storage) + storage = mocker.MagicMock(spec=Storage) mock_data = { "test": { "test_property": "stored_value", "new_property": "new_value", } } - storage.read = AsyncMock(return_value=mock_data) + storage.read = mocker.AsyncMock(return_value=mock_data) test_state = await StateForTesting.load(context, storage) @@ -207,64 +196,64 @@ class TestStatePropertyAccessor: """Tests for the StatePropertyAccessor class.""" @pytest.mark.asyncio - async def test_get_existing_property(self): + async def test_get_existing_property(self, mocker): """Test getting an existing property.""" test_state = StateForTesting() test_state.test_property = "existing_value" accessor = StatePropertyAccessor(test_state, "test_property") - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) value = await accessor.get(context) assert value == "existing_value" @pytest.mark.asyncio - async def test_get_non_existent_without_default(self): + async def test_get_non_existent_without_default(self, mocker): """Test getting a non-existent property without a default value.""" test_state = StateForTesting() accessor = StatePropertyAccessor(test_state, "non_existent") - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) value = await accessor.get(context) assert value is None @pytest.mark.asyncio - async def test_get_with_default_value(self): + async def test_get_with_default_value(self, mocker): """Test getting a property with a default value.""" test_state = StateForTesting() accessor = StatePropertyAccessor(test_state, "non_existent") - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) value = await accessor.get(context, "default") assert value == "default" @pytest.mark.asyncio - async def test_get_with_default_factory(self): + async def test_get_with_default_factory(self, mocker): """Test getting a property with a default factory function.""" test_state = StateForTesting() accessor = StatePropertyAccessor(test_state, "non_existent") - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) value = await accessor.get(context, lambda: "factory_default") assert value == "factory_default" @pytest.mark.asyncio - async def test_set_property(self): + async def test_set_property(self, mocker): """Test setting a property value.""" test_state = StateForTesting() accessor = StatePropertyAccessor(test_state, "test_property") - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) await accessor.set(context, "new_value") assert test_state.test_property == "new_value" @pytest.mark.asyncio - async def test_delete_property(self): + async def test_delete_property(self, mocker): """Test deleting a property.""" test_state = StateForTesting() test_state.test_property = "value_to_delete" accessor = StatePropertyAccessor(test_state, "test_property") - context = MagicMock(spec=TurnContext) + context = mocker.MagicMock(spec=TurnContext) await accessor.delete(context) assert "test_property" not in test_state diff --git a/tests/hosting_core/oauth/__init__.py b/tests/hosting_core/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/test_flow_state.py b/tests/hosting_core/oauth/test_flow_state.py similarity index 99% rename from tests/hosting_core/test_flow_state.py rename to tests/hosting_core/oauth/test_flow_state.py index 3d12f48c..9e8b7266 100644 --- a/tests/hosting_core/test_flow_state.py +++ b/tests/hosting_core/oauth/test_flow_state.py @@ -1,12 +1,9 @@ from datetime import datetime - import pytest - from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag class TestFlowState: - @pytest.mark.parametrize( "original_flow_state, refresh_to_not_started", [ diff --git a/tests/hosting_core/test_flow_storage_client.py b/tests/hosting_core/oauth/test_flow_storage_client.py similarity index 63% rename from tests/hosting_core/test_flow_storage_client.py rename to tests/hosting_core/oauth/test_flow_storage_client.py index 090850d3..efad76b0 100644 --- a/tests/hosting_core/test_flow_storage_client.py +++ b/tests/hosting_core/oauth/test_flow_storage_client.py @@ -4,25 +4,19 @@ from microsoft_agents.hosting.core.oauth import FlowState, FlowStorageClient from tests._common.storage.utils import MockStoreItem +from tests._common.data import TEST_DEFAULTS +DEFAULTS = TEST_DEFAULTS() -class TestFlowStorageClient: - - @pytest.fixture - def channel_id(self): - return "__channel_id" - - @pytest.fixture - def user_id(self): - return "__user_id" +class TestFlowStorageClient: @pytest.fixture def storage(self): return MemoryStorage() @pytest.fixture - def client(self, channel_id, user_id, storage): - return FlowStorageClient(channel_id, user_id, storage) + def client(self, storage): + return FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) @pytest.mark.asyncio @pytest.mark.parametrize( @@ -38,14 +32,14 @@ async def test_init_base_key(self, mocker, channel_id, user_id): assert client.base_key == f"auth/{channel_id}/{user_id}/" @pytest.mark.asyncio - async def test_init_fails_without_user_id(self, channel_id, storage): + async def test_init_fails_without_user_id(self, storage): with pytest.raises(ValueError): - FlowStorageClient(channel_id, "", storage) + FlowStorageClient(DEFAULTS.channel_id, "", storage) @pytest.mark.asyncio - async def test_init_fails_without_channel_id(self, user_id, storage): + async def test_init_fails_without_channel_id(self, storage): with pytest.raises(ValueError): - FlowStorageClient("", user_id, storage) + FlowStorageClient("", DEFAULTS.user_id, storage) @pytest.mark.parametrize( "auth_handler_id, expected", @@ -59,11 +53,11 @@ def test_key(self, client, auth_handler_id, expected): @pytest.mark.asyncio @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) - async def test_read(self, mocker, user_id, channel_id, auth_handler_id): + async def test_read(self, mocker, auth_handler_id): storage = mocker.AsyncMock() - key = f"auth/{channel_id}/{user_id}/{auth_handler_id}" + key = f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/{auth_handler_id}" storage.read.return_value = {key: FlowState()} - client = FlowStorageClient(channel_id, user_id, storage) + client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) res = await client.read(auth_handler_id) assert res is storage.read.return_value[key] storage.read.assert_called_once_with( @@ -83,10 +77,10 @@ async def test_read_missing(self, mocker): @pytest.mark.asyncio @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) - async def test_write(self, mocker, channel_id, user_id, auth_handler_id): + async def test_write(self, mocker, auth_handler_id): storage = mocker.AsyncMock() storage.write.return_value = None - client = FlowStorageClient(channel_id, user_id, storage) + client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) flow_state = mocker.Mock(spec=FlowState) flow_state.auth_handler_id = auth_handler_id await client.write(flow_state) @@ -94,33 +88,31 @@ async def test_write(self, mocker, channel_id, user_id, auth_handler_id): @pytest.mark.asyncio @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) - async def test_delete(self, mocker, channel_id, user_id, auth_handler_id): + async def test_delete(self, mocker, auth_handler_id): storage = mocker.AsyncMock() storage.delete.return_value = None - client = FlowStorageClient(channel_id, user_id, storage) + client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) await client.delete(auth_handler_id) storage.delete.assert_called_once_with([client.key(auth_handler_id)]) @pytest.mark.asyncio - async def test_integration_with_memory_storage(self, channel_id, user_id): + async def test_integration_with_memory_storage(self): - flow_state_alpha = FlowState(auth_handler_id="handler", flow_started=True) - flow_state_beta = FlowState( - auth_handler_id="auth_handler", flow_started=True, user_token="token" - ) + flow_state_alpha = FlowState(auth_handler_id="handler") + flow_state_beta = FlowState(auth_handler_id="auth_handler", user_token="token") storage = MemoryStorage( { "some_data": MockStoreItem({"value": "test"}), - f"auth/{channel_id}/{user_id}/handler": flow_state_alpha, - f"auth/{channel_id}/{user_id}/auth_handler": flow_state_beta, + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": flow_state_alpha, + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta, } ) baseline = MemoryStorage( { "some_data": MockStoreItem({"value": "test"}), - f"auth/{channel_id}/{user_id}/handler": flow_state_alpha, - f"fauth/{channel_id}/{user_id}/auth_handler": flow_state_beta, + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": flow_state_alpha, + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta, } ) @@ -138,7 +130,7 @@ async def delete_both(*args, **kwargs): await storage.delete(*args, **kwargs) await baseline.delete(*args, **kwargs) - client = FlowStorageClient(channel_id, user_id, storage) + client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) new_flow_state_alpha = FlowState(auth_handler_id="handler") flow_state_chi = FlowState(auth_handler_id="chi") @@ -146,26 +138,40 @@ async def delete_both(*args, **kwargs): await client.write(new_flow_state_alpha) await client.write(flow_state_chi) await baseline.write( - {f"auth/{channel_id}/{user_id}/handler": new_flow_state_alpha.model_copy()} + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": new_flow_state_alpha.model_copy() + } ) await baseline.write( - {f"auth/{channel_id}/{user_id}/chi": flow_state_chi.model_copy()} + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi": flow_state_chi.model_copy() + } ) await write_both( - {f"auth/{channel_id}/{user_id}/handler": new_flow_state_alpha.model_copy()} + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": new_flow_state_alpha.model_copy() + } ) await write_both( - {f"auth/{channel_id}/{user_id}/auth_handler": flow_state_beta.model_copy()} + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta.model_copy() + } ) await write_both({"other_data": MockStoreItem({"value": "more"})}) await delete_both(["some_data"]) - await read_check([f"auth/{channel_id}/{user_id}/handler"], target_cls=FlowState) await read_check( - [f"auth/{channel_id}/{user_id}/auth_handler"], target_cls=FlowState + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler"], + target_cls=FlowState, + ) + await read_check( + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler"], + target_cls=FlowState, + ) + await read_check( + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], target_cls=FlowState ) - await read_check([f"auth/{channel_id}/{user_id}/chi"], target_cls=FlowState) await read_check(["other_data"], target_cls=MockStoreItem) await read_check(["some_data"], target_cls=MockStoreItem) diff --git a/tests/hosting_core/oauth/test_oauth_flow.py b/tests/hosting_core/oauth/test_oauth_flow.py new file mode 100644 index 00000000..62b75b53 --- /dev/null +++ b/tests/hosting_core/oauth/test_oauth_flow.py @@ -0,0 +1,590 @@ +import pytest + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + TokenResponse, + SignInResource, + TokenExchangeState, + ConversationReference, +) +from microsoft_agents.hosting.core.oauth import ( + OAuthFlow, + FlowErrorTag, + FlowStateTag, + FlowResponse, +) + +from tests._common.data import TEST_DEFAULTS, TEST_FLOW_DATA +from tests._common.data.test_storage_data import FLOW_DATA +from tests._common.fixtures import FlowStateFixtures +from tests._common.testing_objects import mock_UserTokenClient + +DEFAULTS = TEST_DEFAULTS() +FLOW_DATA = TEST_FLOW_DATA() + + +def testing_Activity( + mocker, + type=ActivityTypes.message, + name="a", + value=None, + text="a", +): + # mock_conversation_ref = mocker.MagicMock(ConversationReference) + mocker.patch.object( + Activity, + "get_conversation_reference", + return_value=mocker.MagicMock(ConversationReference), + ) + return Activity( + type=type, + name=name, + from_property=ChannelAccount(id=DEFAULTS.user_id), + channel_id=DEFAULTS.channel_id, + # get_conversation_reference=mocker.Mock(return_value=conv_ref), + relates_to=mocker.MagicMock(ConversationReference), + value=value, + text=text, + ) + + +class TestUtils(FlowStateFixtures): + def setup_method(self, mocker): + self.UserTokenClient = mock_UserTokenClient + self.Activity = testing_Activity + + @pytest.fixture + def user_token_client(self, mocker): + return self.UserTokenClient(mocker, get_token_return=DEFAULTS.token) + + @pytest.fixture + def activity(self, mocker): + return self.Activity(mocker) + + @pytest.fixture + def flow(self, flow_state, user_token_client): + return OAuthFlow(flow_state, user_token_client) + + +class TestOAuthFlow(TestUtils): + def test_init_no_user_token_client(self, flow_state): + with pytest.raises(ValueError): + OAuthFlow(flow_state, None) + + @pytest.mark.parametrize( + "missing_value", ["connection", "ms_app_id", "channel_id", "user_id"] + ) + def test_init_errors(self, missing_value, user_token_client): + started_flow_state = FLOW_DATA.started.model_copy() + flow_state = started_flow_state + flow_state.__setattr__(missing_value, None) + with pytest.raises(ValueError): + OAuthFlow(flow_state, user_token_client) + flow_state.__setattr__(missing_value, "") + with pytest.raises(ValueError): + OAuthFlow(flow_state, user_token_client) + + def test_init_with_state(self, flow_state, user_token_client): + flow = OAuthFlow(flow_state, user_token_client) + assert flow.flow_state == flow_state + + def test_flow_state_prop_copy(self, flow): + flow_state = flow.flow_state + flow_state.user_id = flow_state.user_id + "_copy" + assert flow.flow_state.user_id == flow.flow_state.user_id + assert flow_state.user_id == f"{flow.flow_state.user_id}_copy" + + @pytest.mark.asyncio + async def test_get_user_token_success(self, flow_state, user_token_client): + # setup + flow = OAuthFlow(flow_state, user_token_client) + expected_final_flow_state = flow_state + expected_final_flow_state.user_token = DEFAULTS.token + expected_final_flow_state.tag = FlowStateTag.COMPLETE + + # test + token_response = await flow.get_user_token() + + # verify + token = token_response.token + assert token == DEFAULTS.token + expected_final_flow_state.expiration = flow.flow_state.expiration + assert flow.flow_state == expected_final_flow_state + user_token_client.user_token.get_token.assert_called_once_with( + user_id=flow_state.user_id, + connection_name=flow_state.connection, + channel_id=flow_state.channel_id, + code=None, + ) + + @pytest.mark.asyncio + async def test_get_user_token_failure(self, mocker, flow_state): + # setup + user_token_client = self.UserTokenClient( + mocker, get_token_return=TokenResponse() + ) + flow = OAuthFlow(flow_state, user_token_client) + expected_final_flow_state = flow.flow_state + + # test + token_response = await flow.get_user_token() + + # verify + assert token_response == TokenResponse() + assert flow.flow_state == expected_final_flow_state + user_token_client.user_token.get_token.assert_called_once_with( + user_id=flow_state.user_id, + connection_name=flow_state.connection, + channel_id=flow_state.channel_id, + code=None, + ) + + @pytest.mark.asyncio + async def test_sign_out(self, flow_state, user_token_client): + # setup + flow = OAuthFlow(flow_state, user_token_client) + expected_flow_state = flow_state + expected_flow_state.user_token = "" + expected_flow_state.tag = FlowStateTag.NOT_STARTED + + # test + await flow.sign_out() + + # verify + user_token_client.user_token.sign_out.assert_called_once_with( + user_id=flow_state.user_id, + connection_name=flow_state.connection, + channel_id=flow_state.channel_id, + ) + assert flow.flow_state == expected_flow_state + + @pytest.mark.asyncio + async def test_begin_flow_easy_case(self, mocker, flow_state, activity): + # setup + user_token_client = self.UserTokenClient( + mocker, get_token_return=TokenResponse(token=DEFAULTS.token) + ) + flow = OAuthFlow(flow_state, user_token_client) + expected_flow_state = flow_state + expected_flow_state.user_token = DEFAULTS.token + expected_flow_state.tag = FlowStateTag.COMPLETE + + # test + response = await flow.begin_flow(activity) + + # verify + out_flow_state = flow.flow_state + expected_flow_state.expiration = out_flow_state.expiration + assert out_flow_state == expected_flow_state + + assert response.flow_state == out_flow_state + assert response.sign_in_resource is None # No sign-in resource in this case + assert response.flow_error_tag == FlowErrorTag.NONE + assert response.token_response + assert response.token_response.token == DEFAULTS.token + user_token_client.user_token.get_token.assert_called_once_with( + user_id=flow_state.user_id, + connection_name=flow_state.connection, + channel_id=flow_state.channel_id, + code=None, + ) + + @pytest.mark.asyncio + async def test_begin_flow_long_case(self, mocker, flow_state, activity): + # resources + mocker.patch.object( + TokenExchangeState, "get_encoded_state", return_value="encoded_state" + ) + dummy_sign_in_resource = SignInResource( + sign_in_link="https://example.com/signin", + ) + user_token_client = self.UserTokenClient( + mocker, + get_token_return=TokenResponse(), + get_sign_in_resource_return=dummy_sign_in_resource, + ) + + # setup + flow = OAuthFlow(flow_state, user_token_client) + expected_flow_state = flow_state + expected_flow_state.user_token = "" + expected_flow_state.tag = FlowStateTag.BEGIN + expected_flow_state.attempts_remaining = 3 + expected_flow_state.continuation_activity = activity + + # test + response = await flow.begin_flow(activity) + + # verify flow_state + out_flow_state = flow.flow_state + expected_flow_state.expiration = ( + out_flow_state.expiration + ) # robrandao: TODO -> ignore this for now + assert out_flow_state == response.flow_state + assert out_flow_state == expected_flow_state + + # verify FlowResponse + assert response.sign_in_resource == dummy_sign_in_resource + assert response.flow_error_tag == FlowErrorTag.NONE + assert not response.token_response + # robrandao: TODO more assertions on sign_in_resource + + @pytest.mark.asyncio + async def test_continue_flow_not_active( + self, inactive_flow_state, user_token_client, activity + ): + # setup + flow = OAuthFlow(inactive_flow_state, user_token_client) + expected_flow_state = inactive_flow_state + expected_flow_state.tag = FlowStateTag.FAILURE + + # test + flow_response = await flow.continue_flow(activity) + out_flow_state = flow.flow_state + + # verify + assert out_flow_state == expected_flow_state + assert flow_response.flow_state == out_flow_state + assert not flow_response.token_response + + async def helper_continue_flow_failure( + self, active_flow_state, user_token_client, activity, flow_error_tag + ): + # setup + flow = OAuthFlow(active_flow_state, user_token_client) + expected_flow_state = active_flow_state + expected_flow_state.tag = ( + FlowStateTag.CONTINUE + if active_flow_state.attempts_remaining > 1 + else FlowStateTag.FAILURE + ) + expected_flow_state.attempts_remaining = ( + active_flow_state.attempts_remaining - 1 + ) + + # test + flow_response = await flow.continue_flow(activity) + out_flow_state = flow.flow_state + + # verify + assert flow_response.flow_state == out_flow_state + assert expected_flow_state == out_flow_state + assert flow_response.token_response == TokenResponse() + assert flow_response.flow_error_tag == flow_error_tag + + async def helper_continue_flow_success( + self, active_flow_state, user_token_client, activity, expected_token + ): + # setup + flow = OAuthFlow(active_flow_state, user_token_client) + expected_flow_state = active_flow_state + expected_flow_state.tag = FlowStateTag.COMPLETE + expected_flow_state.user_token = DEFAULTS.token + expected_flow_state.attempts_remaining = active_flow_state.attempts_remaining + + # test + flow_response = await flow.continue_flow(activity) + out_flow_state = flow.flow_state + expected_flow_state.expiration = ( + out_flow_state.expiration + ) # robrandao: TODO -> ignore this for now + + # verify + assert flow_response.flow_state == out_flow_state + assert expected_flow_state == out_flow_state + assert flow_response.token_response == TokenResponse(token=expected_token) + assert flow_response.flow_error_tag == FlowErrorTag.NONE + + @pytest.mark.asyncio + @pytest.mark.parametrize("magic_code", ["magic", "123", "", "1239453"]) + async def test_continue_flow_active_message_magic_format_error( + self, mocker, active_flow_state, user_token_client, magic_code + ): + # setup + activity = self.Activity(mocker, type=ActivityTypes.message, text=magic_code) + await self.helper_continue_flow_failure( + active_flow_state, + user_token_client, + activity, + FlowErrorTag.MAGIC_FORMAT, + ) + user_token_client.user_token.get_token.assert_not_called() + + @pytest.mark.asyncio + async def test_continue_flow_active_message_magic_code_error( + self, mocker, active_flow_state + ): + # setup + user_token_client = self.UserTokenClient( + mocker, get_token_return=TokenResponse() + ) + activity = self.Activity(mocker, type=ActivityTypes.message, text="123456") + await self.helper_continue_flow_failure( + active_flow_state, + user_token_client, + activity, + FlowErrorTag.MAGIC_CODE_INCORRECT, + ) + user_token_client.user_token.get_token.assert_called_once_with( + user_id=active_flow_state.user_id, + connection_name=active_flow_state.connection, + channel_id=active_flow_state.channel_id, + code="123456", + ) + + @pytest.mark.asyncio + async def test_continue_flow_active_message_success( + self, mocker, active_flow_state, user_token_client + ): + # setup + user_token_client = self.UserTokenClient( + mocker, get_token_return=TokenResponse(token=DEFAULTS.token) + ) + activity = self.Activity(mocker, ActivityTypes.message, text="123456") + await self.helper_continue_flow_success( + active_flow_state, + user_token_client, + activity, + expected_token=DEFAULTS.token, + ) + user_token_client.user_token.get_token.assert_called_once_with( + user_id=active_flow_state.user_id, + connection_name=active_flow_state.connection, + channel_id=active_flow_state.channel_id, + code="123456", + ) + + @pytest.mark.asyncio + async def test_continue_flow_active_sign_in_verify_state_error( + self, mocker, active_flow_state + ): + # setup + user_token_client = self.UserTokenClient( + mocker, get_token_return=TokenResponse() + ) + activity = self.Activity( + mocker, + type=ActivityTypes.invoke, + name="signin/verifyState", + value={"state": "magic_code"}, + ) + await self.helper_continue_flow_failure( + active_flow_state, user_token_client, activity, FlowErrorTag.OTHER + ) + user_token_client.user_token.get_token.assert_called_once_with( + user_id=active_flow_state.user_id, + connection_name=active_flow_state.connection, + channel_id=active_flow_state.channel_id, + code="magic_code", + ) + + @pytest.mark.asyncio + async def test_continue_flow_active_sign_in_verify_success( + self, + mocker, + active_flow_state, + ): + user_token_client = self.UserTokenClient( + mocker, get_token_return=TokenResponse(token=DEFAULTS.token) + ) + activity = self.Activity( + mocker, + type=ActivityTypes.invoke, + name="signin/verifyState", + value={"state": "magic_code"}, + ) + await self.helper_continue_flow_success( + active_flow_state, + user_token_client, + activity, + expected_token=DEFAULTS.token, + ) + user_token_client.user_token.get_token.assert_called_once_with( + user_id=active_flow_state.user_id, + connection_name=active_flow_state.connection, + channel_id=active_flow_state.channel_id, + code="magic_code", + ) + + @pytest.mark.asyncio + async def test_continue_flow_active_sign_in_token_exchange_error( + self, mocker, active_flow_state + ): + token_exchange_request = {} + user_token_client = self.UserTokenClient( + mocker, exchange_token_return=TokenResponse() + ) + activity = self.Activity( + mocker, + type=ActivityTypes.invoke, + name="signin/tokenExchange", + value=token_exchange_request, + ) + await self.helper_continue_flow_failure( + active_flow_state, user_token_client, activity, FlowErrorTag.OTHER + ) + user_token_client.user_token.exchange_token.assert_called_once_with( + user_id=active_flow_state.user_id, + connection_name=active_flow_state.connection, + channel_id=active_flow_state.channel_id, + body=token_exchange_request, + ) + + @pytest.mark.asyncio + async def test_continue_flow_active_sign_in_token_exchange_success( + self, mocker, active_flow_state + ): + token_exchange_request = {} + user_token_client = self.UserTokenClient( + mocker, exchange_token_return=TokenResponse(token=DEFAULTS.token) + ) + activity = self.Activity( + mocker, + type=ActivityTypes.invoke, + name="signin/tokenExchange", + value=token_exchange_request, + ) + await self.helper_continue_flow_success( + active_flow_state, + user_token_client, + activity, + expected_token=DEFAULTS.token, + ) + user_token_client.user_token.exchange_token.assert_called_once_with( + user_id=active_flow_state.user_id, + connection_name=active_flow_state.connection, + channel_id=active_flow_state.channel_id, + body=token_exchange_request, + ) + + @pytest.mark.asyncio + async def test_continue_flow_invalid_invoke_name( + self, mocker, active_flow_state, user_token_client + ): + with pytest.raises(ValueError): + activity = self.Activity( + mocker, type=ActivityTypes.invoke, name="other", value={} + ) + flow = OAuthFlow(active_flow_state, user_token_client) + await flow.continue_flow(activity) + + @pytest.mark.asyncio + async def test_continue_flow_invalid_activity_type( + self, mocker, active_flow_state, user_token_client + ): + with pytest.raises(ValueError): + activity = self.Activity( + mocker, type=ActivityTypes.command, name="other", value={} + ) + flow = OAuthFlow(active_flow_state, user_token_client) + await flow.continue_flow(activity) + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_not_started_flow( + self, + mocker, + activity, + ): + # setup + not_started_flow_state = FLOW_DATA.not_started.model_copy() + expected_response = FlowResponse( + flow_state=not_started_flow_state, + token_response=TokenResponse(token=not_started_flow_state.user_token), + ) + mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) + + flow = OAuthFlow(not_started_flow_state, mocker.Mock()) + + # test + actual_response = await flow.begin_or_continue_flow(activity) + + # verify + assert actual_response is expected_response + OAuthFlow.begin_flow.assert_called_once_with(activity) + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_inactive_flow( + self, mocker, inactive_flow_state_not_completed, activity + ): + # mock + expected_response = FlowResponse( + flow_state=inactive_flow_state_not_completed, + token_response=TokenResponse(), + ) + mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) + + # setup + flow = OAuthFlow(inactive_flow_state_not_completed, mocker.Mock()) + + # test + actual_response = await flow.begin_or_continue_flow(activity) + + # verify + assert actual_response is expected_response + OAuthFlow.begin_flow.assert_called_once_with(activity) + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_active_flow( + self, mocker, active_flow_state, activity, user_token_client + ): + # mock + expected_response = FlowResponse( + flow_state=active_flow_state, + token_response=TokenResponse(token=active_flow_state.user_token), + ) + mocker.patch.object(OAuthFlow, "continue_flow", return_value=expected_response) + + # setup + flow = OAuthFlow(active_flow_state, user_token_client) + + # test + actual_response = await flow.begin_or_continue_flow(activity) + + # verify + assert actual_response is expected_response + OAuthFlow.continue_flow.assert_called_once_with(activity) + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_stale_flow_state( + self, + mocker, + activity, + ): + # mock + expired_flow_state = FLOW_DATA.active_exp.model_copy() + expected_response = FlowResponse() + mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) + + # setup + flow = OAuthFlow(expired_flow_state, mocker.Mock()) + + # test + actual_response = await flow.begin_or_continue_flow(activity) + + # verify + assert actual_response is expected_response + OAuthFlow.begin_flow.assert_called_once_with(activity) + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_completed_flow_state(self, mocker, activity): + completed_flow_state = FLOW_DATA.completed.model_copy() + # mock + mocker.patch.object(OAuthFlow, "begin_flow", return_value=None) + mocker.patch.object(OAuthFlow, "continue_flow", return_value=None) + + # setup + expected_response = FlowResponse( + flow_state=completed_flow_state, + token_response=TokenResponse(token=completed_flow_state.user_token), + ) + flow = OAuthFlow(completed_flow_state, mocker.Mock()) + + # test + actual_response = await flow.begin_or_continue_flow(activity) + + # verify + assert actual_response == expected_response + OAuthFlow.begin_flow.assert_not_called() + OAuthFlow.continue_flow.assert_not_called() diff --git a/tests/hosting_core/state/__init__.py b/tests/hosting_core/state/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/test_agent_state.py b/tests/hosting_core/state/test_agent_state.py similarity index 95% rename from tests/hosting_core/test_agent_state.py rename to tests/hosting_core/state/test_agent_state.py index 5df89fed..4a7b069d 100644 --- a/tests/hosting_core/test_agent_state.py +++ b/tests/hosting_core/state/test_agent_state.py @@ -7,11 +7,9 @@ import asyncio import pytest -from typing import Type from unittest.mock import AsyncMock, MagicMock from microsoft_agents.hosting.core.state.agent_state import ( - AgentState, CachedAgentState, BotStatePropertyAccessor, ) @@ -25,30 +23,7 @@ ChannelAccount, ConversationAccount, ) -from .tools.testing_adapter import TestingAdapter - - -class MockCustomState(AgentState): - """Custom state implementation for testing.""" - - def __init__(self, storage: Storage, namespace: str = ""): - self.namespace = namespace - super().__init__(storage, "MockCustomState") - - def get_storage_key( - self, turn_context: TurnContext, *, target_cls: Type[StoreItem] = None - ) -> str: - """ - Returns the storage key for the custom state. - """ - conversation_id = turn_context.activity.conversation.id - if not conversation_id: - raise ValueError("Invalid activity: missing conversation.id") - - key = f"custom/{conversation_id}" - if self.namespace: - key = f"{self.namespace}/{key}" - return key +from tests._common.testing_objects import TestingAdapter, TestingCustomState class TestDataItem(StoreItem): @@ -77,7 +52,7 @@ def setup_method(self): self.storage = MemoryStorage() self.user_state = UserState(self.storage) self.conversation_state = ConversationState(self.storage) - self.custom_state = MockCustomState(self.storage) + self.custom_state = TestingCustomState(self.storage) # Create a test context self.adapter = TestingAdapter() diff --git a/tests/hosting_core/storage/__init__.py b/tests/hosting_core/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/test_memory_storage.py b/tests/hosting_core/storage/test_memory_storage.py similarity index 100% rename from tests/hosting_core/test_memory_storage.py rename to tests/hosting_core/storage/test_memory_storage.py diff --git a/tests/hosting_core/test_error_handling.py b/tests/hosting_core/test_error_handling.py index b35c1a3a..ff341af5 100644 --- a/tests/hosting_core/test_error_handling.py +++ b/tests/hosting_core/test_error_handling.py @@ -16,7 +16,6 @@ async def raise_custom_error(code: int): @pytest.mark.asyncio async def test_ignore_error_without_error(): - async def func(): return 42 @@ -37,7 +36,6 @@ async def test_ignore_error_with_ignored_error(): @pytest.mark.asyncio async def test_is_status_code_with_status_code_check(): - async def func(): return 42 @@ -58,4 +56,4 @@ async def raise_exception(): raise Exception() with pytest.raises(Exception): - await ignore_error(raise_exception, is_status_code_error(404)) + await ignore_error(raise_exception(), is_status_code_error(404)) diff --git a/tests/hosting_core/test_oauth_flow.py b/tests/hosting_core/test_oauth_flow.py deleted file mode 100644 index ea6268b8..00000000 --- a/tests/hosting_core/test_oauth_flow.py +++ /dev/null @@ -1,604 +0,0 @@ -import pytest - -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - TokenResponse, - SignInResource, - TokenExchangeState, - ConversationReference, - ChannelAccount, -) -from microsoft_agents.hosting.core.oauth import ( - OAuthFlow, - FlowErrorTag, - FlowStateTag, - FlowResponse, -) -from microsoft_agents.hosting.core.connector.user_token_base import UserTokenBase -from microsoft_agents.hosting.core.connector.user_token_client_base import ( - UserTokenClientBase, -) - -# test constants -from .tools.testing_oauth import * - - -class TestOAuthFlowUtils: - - def create_user_token_client(self, mocker, get_token_return=None): - - user_token_client = mocker.Mock(spec=UserTokenClientBase) - user_token_client.user_token = mocker.Mock(spec=UserTokenBase) - user_token_client.user_token.get_token = mocker.AsyncMock() - user_token_client.user_token.sign_out = mocker.AsyncMock() - - return_value = TokenResponse() - if get_token_return: - return_value = TokenResponse(token=get_token_return) - user_token_client.user_token.get_token.return_value = return_value - - return user_token_client - - @pytest.fixture - def user_token_client(self, mocker): - return self.create_user_token_client(mocker, get_token_return=RES_TOKEN) - - def create_activity( - self, - mocker, - activity_type=ActivityTypes.message, - name="a", - value=None, - text="a", - ): - # def conv_ref(): - # return mocker.MagicMock(spec=ConversationReference) - mock_conversation_ref = mocker.MagicMock(ConversationReference) - mocker.patch.object( - Activity, - "get_conversation_reference", - return_value=mocker.MagicMock(ConversationReference), - ) - # mocker.patch.object(ConversationReference, "create", return_value=conv_ref()) - return Activity( - type=activity_type, - name=name, - from_property=ChannelAccount(id=USER_ID), - channel_id=CHANNEL_ID, - # get_conversation_reference=mocker.Mock(return_value=conv_ref), - relates_to=mocker.MagicMock(ConversationReference), - value=value, - text=text, - ) - - @pytest.fixture(params=FLOW_STATES.ALL()) - def sample_flow_state(self, request): - return request.param.model_copy() - - @pytest.fixture(params=FLOW_STATES.FAILED()) - def sample_failed_flow_state(self, request): - return request.param.model_copy() - - @pytest.fixture(params=FLOW_STATES.INACTIVE()) - def sample_inactive_flow_state(self, request): - return request.param.model_copy() - - @pytest.fixture( - params=[ - flow_state - for flow_state in FLOW_STATES.INACTIVE() - if flow_state.tag != FlowStateTag.COMPLETE - ] - ) - def sample_inactive_flow_state_not_completed(self, request): - return request.param.model_copy() - - @pytest.fixture(params=FLOW_STATES.ACTIVE()) - def sample_active_flow_state(self, request): - return request.param.model_copy() - - @pytest.fixture - def flow(self, sample_flow_state, user_token_client): - return OAuthFlow(sample_flow_state, user_token_client) - - -class TestOAuthFlow(TestOAuthFlowUtils): - - def test_init_no_user_token_client(self, sample_flow_state): - with pytest.raises(ValueError): - OAuthFlow(sample_flow_state, None) - - @pytest.mark.parametrize( - "missing_value", ["connection", "ms_app_id", "channel_id", "user_id"] - ) - def test_init_errors(self, missing_value, user_token_client): - flow_state = FLOW_STATES.STARTED_FLOW.model_copy() - flow_state.__setattr__(missing_value, None) - with pytest.raises(ValueError): - OAuthFlow(flow_state, user_token_client) - flow_state.__setattr__(missing_value, "") - with pytest.raises(ValueError): - OAuthFlow(flow_state, user_token_client) - - def test_init_with_state(self, sample_flow_state, user_token_client): - flow = OAuthFlow(sample_flow_state, user_token_client) - assert flow.flow_state == sample_flow_state - - def test_flow_state_prop_copy(self, flow): - flow_state = flow.flow_state - flow_state.user_id = flow_state.user_id + "_copy" - assert flow.flow_state.user_id == USER_ID - assert flow_state.user_id == f"{USER_ID}_copy" - - @pytest.mark.asyncio - async def test_get_user_token_success(self, sample_flow_state, user_token_client): - # setup - flow = OAuthFlow(sample_flow_state, user_token_client) - expected_final_flow_state = sample_flow_state - expected_final_flow_state.user_token = RES_TOKEN - expected_final_flow_state.tag = FlowStateTag.COMPLETE - - # test - token_response = await flow.get_user_token() - token = token_response.token - - # verify - assert token == RES_TOKEN - expected_final_flow_state.expiration = flow.flow_state.expiration - assert flow.flow_state == expected_final_flow_state - user_token_client.user_token.get_token.assert_called_once_with( - user_id=USER_ID, - connection_name=ABS_OAUTH_CONNECTION_NAME, - channel_id=CHANNEL_ID, - code=None, - ) - - @pytest.mark.asyncio - async def test_get_user_token_failure(self, mocker, sample_flow_state): - # setup - user_token_client = self.create_user_token_client(mocker, get_token_return=None) - flow = OAuthFlow(sample_flow_state, user_token_client) - expected_final_flow_state = flow.flow_state - - # test - token_response = await flow.get_user_token() - - # verify - assert token_response == TokenResponse() - assert flow.flow_state == expected_final_flow_state - user_token_client.user_token.get_token.assert_called_once_with( - user_id=USER_ID, - connection_name=ABS_OAUTH_CONNECTION_NAME, - channel_id=CHANNEL_ID, - code=None, - ) - - @pytest.mark.asyncio - async def test_sign_out(self, sample_flow_state, user_token_client): - # setup - flow = OAuthFlow(sample_flow_state, user_token_client) - expected_flow_state = sample_flow_state - expected_flow_state.user_token = "" - expected_flow_state.tag = FlowStateTag.NOT_STARTED - - # test - await flow.sign_out() - - # verify - user_token_client.user_token.sign_out.assert_called_once_with( - user_id=USER_ID, - connection_name=ABS_OAUTH_CONNECTION_NAME, - channel_id=CHANNEL_ID, - ) - assert flow.flow_state == expected_flow_state - - @pytest.mark.asyncio - async def test_begin_flow_easy_case( - self, mocker, sample_flow_state, user_token_client - ): - # setup - flow = OAuthFlow(sample_flow_state, user_token_client) - activity = mocker.Mock(spec=Activity) - expected_flow_state = sample_flow_state - expected_flow_state.user_token = RES_TOKEN - expected_flow_state.tag = FlowStateTag.COMPLETE - - # test - response = await flow.begin_flow(activity) - - # verify - flow_state = flow.flow_state - expected_flow_state.expiration = flow_state.expiration - assert flow_state == expected_flow_state - - assert response.flow_state == flow_state - assert response.sign_in_resource is None # No sign-in resource in this case - assert response.flow_error_tag == FlowErrorTag.NONE - assert response.token_response.token == RES_TOKEN - user_token_client.user_token.get_token.assert_called_once_with( - user_id=USER_ID, - connection_name=ABS_OAUTH_CONNECTION_NAME, - channel_id=CHANNEL_ID, - code=None, - ) - - @pytest.mark.asyncio - async def test_begin_flow_long_case( - self, mocker, sample_flow_state, user_token_client - ): - # mock - # tes = mocker.Mock(TokenExchangeState) - # tes.get_encoded_state = mocker.Mock(return_value="encoded_state") - mocker.patch.object( - TokenExchangeState, "get_encoded_state", return_value="encoded_state" - ) - dummy_sign_in_resource = SignInResource( - sign_in_link="https://example.com/signin", - token_exchange_state=mocker.Mock( - TokenExchangeState, - get_encoded_state=mocker.Mock(return_value="encoded_state"), - ), - ) - user_token_client.user_token.get_token = mocker.AsyncMock( - return_value=TokenResponse() - ) - user_token_client.agent_sign_in.get_sign_in_resource = mocker.AsyncMock( - return_value=dummy_sign_in_resource - ) - activity = self.create_activity(mocker) - - # setup - flow = OAuthFlow(sample_flow_state, user_token_client) - expected_flow_state = sample_flow_state - expected_flow_state.user_token = "" - expected_flow_state.tag = FlowStateTag.BEGIN - expected_flow_state.attempts_remaining = 3 - expected_flow_state.continuation_activity = activity - - # test - response = await flow.begin_flow(activity) - - # verify flow_state - flow_state = flow.flow_state - expected_flow_state.expiration = ( - flow_state.expiration - ) # robrandao: TODO -> ignore this for now - assert flow_state == response.flow_state - assert flow_state == expected_flow_state - - # verify FlowResponse - assert response.sign_in_resource == dummy_sign_in_resource - assert response.flow_error_tag == FlowErrorTag.NONE - assert not response.token_response - # robrandao: TODO more assertions on sign_in_resource - - @pytest.mark.asyncio - async def test_continue_flow_not_active( - self, mocker, sample_inactive_flow_state, user_token_client - ): - # setup - activity = mocker.Mock() - flow = OAuthFlow(sample_inactive_flow_state, user_token_client) - expected_flow_state = sample_inactive_flow_state - expected_flow_state.tag = FlowStateTag.FAILURE - - # test - flow_response = await flow.continue_flow(activity) - flow_state = flow.flow_state - - # verify - assert flow_state == expected_flow_state - assert flow_response.flow_state == flow_state - assert not flow_response.token_response - - async def helper_continue_flow_failure( - self, active_flow_state, user_token_client, activity, flow_error_tag - ): - # setup - flow = OAuthFlow(active_flow_state, user_token_client) - expected_flow_state = active_flow_state - expected_flow_state.tag = ( - FlowStateTag.CONTINUE - if active_flow_state.attempts_remaining > 1 - else FlowStateTag.FAILURE - ) - expected_flow_state.attempts_remaining = ( - active_flow_state.attempts_remaining - 1 - ) - - # test - flow_response = await flow.continue_flow(activity) - flow_state = flow.flow_state - - # verify - assert flow_response.flow_state == flow_state - assert expected_flow_state == flow_state - assert flow_response.token_response == TokenResponse() - assert flow_response.flow_error_tag == flow_error_tag - - async def helper_continue_flow_success( - self, active_flow_state, user_token_client, activity - ): - # setup - flow = OAuthFlow(active_flow_state, user_token_client) - expected_flow_state = active_flow_state - expected_flow_state.tag = FlowStateTag.COMPLETE - expected_flow_state.user_token = RES_TOKEN - expected_flow_state.attempts_remaining = active_flow_state.attempts_remaining - - # test - flow_response = await flow.continue_flow(activity) - flow_state = flow.flow_state - expected_flow_state.expiration = ( - flow_state.expiration - ) # robrandao: TODO -> ignore this for now - - # verify - assert flow_response.flow_state == flow_state - assert expected_flow_state == flow_state - assert flow_response.token_response == TokenResponse(token=RES_TOKEN) - assert flow_response.flow_error_tag == FlowErrorTag.NONE - - @pytest.mark.asyncio - @pytest.mark.parametrize("magic_code", ["magic", "123", "", "1239453"]) - async def test_continue_flow_active_message_magic_format_error( - self, mocker, sample_active_flow_state, user_token_client, magic_code - ): - # setup - activity = self.create_activity(mocker, ActivityTypes.message, text=magic_code) - await self.helper_continue_flow_failure( - sample_active_flow_state, - user_token_client, - activity, - FlowErrorTag.MAGIC_FORMAT, - ) - user_token_client.assert_not_called() - - @pytest.mark.asyncio - async def test_continue_flow_active_message_magic_code_error( - self, mocker, sample_active_flow_state, user_token_client - ): - # setup - user_token_client.user_token.get_token = mocker.AsyncMock( - return_value=TokenResponse() - ) - activity = self.create_activity(mocker, ActivityTypes.message, text="123456") - await self.helper_continue_flow_failure( - sample_active_flow_state, - user_token_client, - activity, - FlowErrorTag.MAGIC_CODE_INCORRECT, - ) - user_token_client.user_token.get_token.assert_called_once_with( - user_id=sample_active_flow_state.user_id, - connection_name=sample_active_flow_state.connection, - channel_id=sample_active_flow_state.channel_id, - code="123456", - ) - - @pytest.mark.asyncio - async def test_continue_flow_active_message_success( - self, mocker, sample_active_flow_state, user_token_client - ): - # setup - activity = self.create_activity(mocker, ActivityTypes.message, text="123456") - await self.helper_continue_flow_success( - sample_active_flow_state, user_token_client, activity - ) - user_token_client.user_token.get_token.assert_called_once_with( - user_id=sample_active_flow_state.user_id, - connection_name=sample_active_flow_state.connection, - channel_id=sample_active_flow_state.channel_id, - code="123456", - ) - - @pytest.mark.asyncio - async def test_continue_flow_active_sign_in_verify_state_error( - self, mocker, sample_active_flow_state, user_token_client - ): - # setup - user_token_client.user_token.get_token = mocker.AsyncMock( - return_value=TokenResponse() - ) - activity = self.create_activity( - mocker, - ActivityTypes.invoke, - name="signin/verifyState", - value={"state": "magic_code"}, - ) - await self.helper_continue_flow_failure( - sample_active_flow_state, user_token_client, activity, FlowErrorTag.OTHER - ) - user_token_client.user_token.get_token.assert_called_once_with( - user_id=sample_active_flow_state.user_id, - connection_name=sample_active_flow_state.connection, - channel_id=sample_active_flow_state.channel_id, - code="magic_code", - ) - - @pytest.mark.asyncio - async def test_continue_flow_active_sign_in_verify_success( - self, mocker, sample_active_flow_state, user_token_client - ): - activity = self.create_activity( - mocker, - ActivityTypes.invoke, - name="signin/verifyState", - value={"state": "magic_code"}, - ) - await self.helper_continue_flow_success( - sample_active_flow_state, user_token_client, activity - ) - user_token_client.user_token.get_token.assert_called_once_with( - user_id=sample_active_flow_state.user_id, - connection_name=sample_active_flow_state.connection, - channel_id=sample_active_flow_state.channel_id, - code="magic_code", - ) - - @pytest.mark.asyncio - async def test_continue_flow_active_sign_in_token_exchange_error( - self, mocker, sample_active_flow_state, user_token_client - ): - token_exchange_request = {} - user_token_client.user_token.exchange_token = mocker.AsyncMock( - return_value=TokenResponse() - ) - activity = self.create_activity( - mocker, - ActivityTypes.invoke, - name="signin/tokenExchange", - value=token_exchange_request, - ) - await self.helper_continue_flow_failure( - sample_active_flow_state, user_token_client, activity, FlowErrorTag.OTHER - ) - user_token_client.user_token.exchange_token.assert_called_once_with( - user_id=sample_active_flow_state.user_id, - connection_name=sample_active_flow_state.connection, - channel_id=sample_active_flow_state.channel_id, - body=token_exchange_request, - ) - - @pytest.mark.asyncio - async def test_continue_flow_active_sign_in_token_exchange_success( - self, mocker, sample_active_flow_state, user_token_client - ): - token_exchange_request = {} - user_token_client.user_token.exchange_token = mocker.AsyncMock( - return_value=TokenResponse(token=RES_TOKEN) - ) - activity = self.create_activity( - mocker, - ActivityTypes.invoke, - name="signin/tokenExchange", - value=token_exchange_request, - ) - await self.helper_continue_flow_success( - sample_active_flow_state, user_token_client, activity - ) - user_token_client.user_token.exchange_token.assert_called_once_with( - user_id=sample_active_flow_state.user_id, - connection_name=sample_active_flow_state.connection, - channel_id=sample_active_flow_state.channel_id, - body=token_exchange_request, - ) - - @pytest.mark.asyncio - async def test_continue_flow_invalid_invoke_name( - self, mocker, sample_active_flow_state, user_token_client - ): - with pytest.raises(ValueError): - activity = self.create_activity( - mocker, ActivityTypes.invoke, name="other", value={} - ) - flow = OAuthFlow(sample_active_flow_state, user_token_client) - await flow.continue_flow(activity) - - @pytest.mark.asyncio - async def test_continue_flow_invalid_activity_type( - self, mocker, sample_active_flow_state, user_token_client - ): - with pytest.raises(ValueError): - activity = self.create_activity( - mocker, ActivityTypes.command, name="other", value={} - ) - flow = OAuthFlow(sample_active_flow_state, user_token_client) - await flow.continue_flow(activity) - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_not_started_flow(self, mocker): - # setup - sample_flow_state = FLOW_STATES.NOT_STARTED_FLOW.model_copy() - expected_response = FlowResponse( - flow_state=sample_flow_state, - token_response=TokenResponse(token=sample_flow_state.user_token), - ) - mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) - - activity_mock = mocker.Mock() - flow = OAuthFlow(sample_flow_state, mocker.Mock()) - - # test - actual_response = await flow.begin_or_continue_flow(activity_mock) - - # verify - assert actual_response is expected_response - OAuthFlow.begin_flow.assert_called_once_with(activity_mock) - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_inactive_flow( - self, - mocker, - sample_inactive_flow_state_not_completed, - ): - # setup - expected_response = FlowResponse( - flow_state=sample_inactive_flow_state_not_completed, - token_response=TokenResponse(), - ) - mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) - - flow = OAuthFlow(sample_inactive_flow_state_not_completed, mocker.Mock()) - - # test - activity_mock = mocker.Mock() - actual_response = await flow.begin_or_continue_flow(activity_mock) - - # verify - assert actual_response is expected_response - OAuthFlow.begin_flow.assert_called_once_with(activity_mock) - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_active_flow( - self, - mocker, - sample_active_flow_state, - ): - # setup - expected_response = FlowResponse( - flow_state=sample_active_flow_state, - token_response=TokenResponse(token=sample_active_flow_state.user_token), - ) - mocker.patch.object(OAuthFlow, "continue_flow", return_value=expected_response) - - flow = OAuthFlow(sample_active_flow_state, mocker.Mock()) - - # test - activity_mock = mocker.Mock() - actual_response = await flow.begin_or_continue_flow(activity_mock) - - # verify - assert actual_response is expected_response - OAuthFlow.continue_flow.assert_called_once_with(activity_mock) - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_stale_flow_state(self, mocker): - flow_state = FLOW_STATES.ACTIVE_EXP_FLOW.model_copy() - expected_response = FlowResponse() - - mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) - - flow = OAuthFlow(flow_state, mocker.Mock()) - actual_response = await flow.begin_or_continue_flow(None) - - assert actual_response is expected_response - OAuthFlow.begin_flow.assert_called_once_with(None) - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_completed_flow_state(self, mocker): - flow_state = FLOW_STATES.COMPLETED_FLOW.model_copy() - expected_response = FlowResponse( - flow_state=flow_state, - token_response=TokenResponse(token=flow_state.user_token), - ) - mocker.patch.object(OAuthFlow, "begin_flow", return_value=None) - mocker.patch.object(OAuthFlow, "continue_flow", return_value=None) - - flow = OAuthFlow(flow_state, mocker.Mock()) - actual_response = await flow.begin_or_continue_flow(None) - - assert actual_response == expected_response - OAuthFlow.begin_flow.assert_not_called() - OAuthFlow.continue_flow.assert_not_called() diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index 9d49a0f3..01305139 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -9,7 +9,7 @@ Entity, ResourceResponse, ) -from microsoft_agents.hosting.core import ChannelAdapter, MessageFactory, TurnContext +from microsoft_agents.hosting.core import MessageFactory, TurnContext, ChannelAdapter activity_data = { "type": "message", @@ -26,8 +26,8 @@ ACTIVITY = Activity(**activity_data) -class MockSimpleAdapter(ChannelAdapter): - async def send_activities(self, context, activities) -> List[ResourceResponse]: +class TestingSimpleAdapter(ChannelAdapter): + async def send_activities(self, context, activities) -> list[ResourceResponse]: responses = [] assert context is not None assert activities is not None @@ -53,11 +53,11 @@ async def delete_activity(self, context, reference): class TestTurnContext: def test_should_create_context_with_request_and_adapter(self): - TurnContext(MockSimpleAdapter(), ACTIVITY) + TurnContext(TestingSimpleAdapter(), ACTIVITY) def test_should_not_create_context_without_request(self): try: - TurnContext(MockSimpleAdapter(), None) + TurnContext(TestingSimpleAdapter(), None) except TypeError: pass except Exception as error: @@ -72,12 +72,12 @@ def test_should_not_create_context_without_adapter(self): raise error def test_should_create_context_with_older_context(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) TurnContext(context) def test_copy_to_should_copy_all_references(self): # pylint: disable=protected-access - old_adapter = MockSimpleAdapter() + old_adapter = TestingSimpleAdapter() old_activity = Activity(id="2", type="message", text="test copy") old_context = TurnContext(old_adapter, old_activity) old_context.responded = True @@ -104,7 +104,7 @@ async def update_activity_handler(context, activity, next_handler): old_context.on_delete_activity(delete_activity_handler) old_context.on_update_activity(update_activity_handler) - adapter = MockSimpleAdapter() + adapter = TestingSimpleAdapter() new_context = TurnContext(adapter, ACTIVITY) assert not new_context._on_send_activities # pylint: disable=protected-access assert not new_context._on_update_activity # pylint: disable=protected-access @@ -126,17 +126,17 @@ async def update_activity_handler(context, activity, next_handler): ) # pylint: disable=protected-access def test_responded_should_be_automatically_set_to_false(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) assert context.responded is False def test_should_be_able_to_set_responded_to_true(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) assert context.responded is False context.responded = True assert context.responded def test_should_not_be_able_to_set_responded_to_false(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) try: context.responded = False except ValueError: @@ -146,7 +146,7 @@ def test_should_not_be_able_to_set_responded_to_false(self): @pytest.mark.asyncio async def test_should_call_on_delete_activity_handlers_before_deletion(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) called = False async def delete_handler(context, reference, next_handler_coroutine): @@ -163,7 +163,7 @@ async def delete_handler(context, reference, next_handler_coroutine): @pytest.mark.asyncio async def test_should_call_multiple_on_delete_activity_handlers_in_order(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) called_first = False called_second = False @@ -201,7 +201,7 @@ async def second_delete_handler(context, reference, next_handler_coroutine): @pytest.mark.asyncio async def test_should_call_send_on_activities_handler_before_send(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) called = False async def send_handler(context, activities, next_handler_coroutine): @@ -218,7 +218,7 @@ async def send_handler(context, activities, next_handler_coroutine): @pytest.mark.asyncio async def test_should_call_on_update_activity_handler_before_update(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) called = False async def update_handler(context, activity, next_handler_coroutine): @@ -236,7 +236,7 @@ async def update_handler(context, activity, next_handler_coroutine): @pytest.mark.asyncio async def test_update_activity_should_apply_conversation_reference(self): activity_id = "activity ID" - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) called = False async def update_handler(context, activity, next_handler_coroutine): @@ -299,7 +299,7 @@ def test_apply_conversation_reference_when_is_incoming_is_true_should_not_prepar async def test_should_get_conversation_reference_using_get_reply_conversation_reference( self, ): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) reply = await context.send_activity("test") assert reply is not None @@ -397,7 +397,7 @@ def test_should_remove_custom_mention_from_activity(self): @pytest.mark.asyncio async def test_should_send_a_trace_activity(self): - context = TurnContext(MockSimpleAdapter(), ACTIVITY) + context = TurnContext(TestingSimpleAdapter(), ACTIVITY) called = False # pylint: disable=unused-argument diff --git a/tests/hosting_core/tools/testing_authorization.py b/tests/hosting_core/tools/testing_authorization.py deleted file mode 100644 index c0f0913a..00000000 --- a/tests/hosting_core/tools/testing_authorization.py +++ /dev/null @@ -1,248 +0,0 @@ -""" -Testing utilities for authorization functionality - -This module provides mock implementations and helper classes for testing authorization, -authentication, and token management scenarios. It includes test doubles for token -providers, connection managers, and authorization handlers that can be configured -to simulate various authentication states and flow conditions. -""" - -from microsoft_agents.hosting.core import ( - Connections, - AccessTokenProviderBase, - AuthHandler, - Authorization, - MemoryStorage, - OAuthFlow, -) -from typing import Dict, Union -from microsoft_agents.hosting.core.authorization.agent_auth_configuration import ( - AgentAuthConfiguration, -) -from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity - -from microsoft_agents.activity import TokenResponse - -from unittest.mock import Mock, AsyncMock - - -def create_test_auth_handler( - name: str, obo: bool = False, title: str = None, text: str = None -): - """ - Creates a test AuthHandler instance with standardized connection names. - - This helper function simplifies the creation of AuthHandler objects for testing - by automatically generating connection names based on the provided name and - optionally including On-Behalf-Of (OBO) connection configuration. - - Args: - name: Base name for the auth handler, used to generate connection names - obo: Whether to include On-Behalf-Of connection configuration - title: Optional title for the auth handler - text: Optional descriptive text for the auth handler - - Returns: - AuthHandler: Configured auth handler instance with test-friendly connection names - """ - return AuthHandler( - name, - abs_oauth_connection_name=f"{name}-abs-connection", - obo_connection_name=f"{name}-obo-connection" if obo else None, - title=title, - text=text, - ) - - -class TestingTokenProvider(AccessTokenProviderBase): - """ - Access token provider for unit tests. - - This test double simulates an access token provider that returns predictable - token values based on the provider name. It implements both standard token - acquisition and On-Behalf-Of (OBO) token flows for comprehensive testing - of authentication scenarios. - """ - - def __init__(self, name: str): - """ - Initialize the testing token provider. - - Args: - name: Identifier used to generate predictable token values - """ - self.name = name - - async def get_access_token( - self, resource_url: str, scopes: list[str], force_refresh: bool = False - ) -> str: - """ - Get an access token for the specified resource and scopes. - - Returns a predictable token string based on the provider name for testing. - - Args: (unused in test implementation) - resource_url: URL of the resource requiring authentication - scopes: List of OAuth scopes requested - force_refresh: Whether to force token refresh - - Returns: - str: Test token in format "{name}-token" - """ - return f"{self.name}-token" - - async def aquire_token_on_behalf_of( - self, scopes: list[str], user_assertion: str - ) -> str: - """ - Acquire a token on behalf of another user (OBO flow). - - Returns a predictable OBO token string for testing scenarios involving - delegated permissions and token exchange. - - Args: (unused in test implementation) - scopes: List of OAuth scopes requested for the OBO token - user_assertion: JWT token representing the user's identity - - Returns: - str: Test OBO token in format "{name}-obo-token" - """ - return f"{self.name}-obo-token" - - -class TestingConnectionManager(Connections): - """ - Connection manager for unit tests. - - This test double provides a simplified connection management interface that - returns TestingTokenProvider instances for all connection requests. It enables - testing of authorization flows without requiring actual OAuth configurations - or external authentication services. - """ - - def get_connection(self, connection_name: str) -> AccessTokenProviderBase: - """ - Get a token provider for the specified connection name. - - Args: - connection_name: Name of the OAuth connection - - Returns: - AccessTokenProviderBase: TestingTokenProvider configured with the connection name - """ - return TestingTokenProvider(connection_name) - - def get_default_connection(self) -> AccessTokenProviderBase: - """ - Get the default token provider. - - Returns: - AccessTokenProviderBase: TestingTokenProvider configured with "default" name - """ - return TestingTokenProvider("default") - - def get_token_provider( - self, claims_identity: ClaimsIdentity, service_url: str - ) -> AccessTokenProviderBase: - """ - Get a token provider based on claims identity and service URL. - - In this test implementation, returns the default connection regardless - of the provided parameters. - - Args: (unused in test implementation) - claims_identity: User's claims and identity information - service_url: URL of the service requiring authentication - - Returns: - AccessTokenProviderBase: The default TestingTokenProvider - """ - return self.get_default_connection() - - def get_default_connection_configuration(self) -> AgentAuthConfiguration: - """ - Get the default authentication configuration. - - Returns: - AgentAuthConfiguration: Empty configuration suitable for testing - """ - return AgentAuthConfiguration() - - -class TestingAuthorization(Authorization): - """ - Authorization system for comprehensive unit testing. - - This test double extends the Authorization class to provide a fully mocked - authorization environment suitable for testing various authentication scenarios. - It automatically configures auth handlers with mock OAuth flows that can simulate - different states like successful authentication, failed sign-in, or in-progress flows. - """ - - def __init__( - self, - auth_handlers: Dict[str, AuthHandler], - token: Union[str, None] = "default", - flow_started=False, - sign_in_failed=False, - ): - """ - Initialize the testing authorization system. - - Sets up a complete test authorization environment with memory storage, - test connection manager, and configures all provided auth handlers with - mock OAuth flows. - - Args: - auth_handlers: Dictionary mapping handler names to AuthHandler instances - token: Token value to use in mock responses. "default" uses auto-generated - tokens, None simulates no token available, or provide custom jwt token string - flow_started: Simulate OAuth flows that have already started - sign_in_failed: Simulate failed sign-in attempts - """ - # Initialize with test-friendly components - storage = MemoryStorage() - connection_manager = TestingConnectionManager() - super().__init__( - storage=storage, - auth_handlers=auth_handlers, - connection_manager=connection_manager, - service_url="a", - ) - - # Configure each auth handler with mock OAuth flow behavior - for auth_handler in self._auth_handlers.values(): - # Create default token response for this auth handler - default_token = TokenResponse( - connection_name=auth_handler.abs_oauth_connection_name, - token=f"{auth_handler.abs_oauth_connection_name}-token", - ) - - # Determine token response based on configuration - if token == "default": - token_response = default_token - elif token: - token_response = TokenResponse( - connection_name=auth_handler.abs_oauth_connection_name, - token=token, - ) - else: - token_response = None - - # Mock the OAuth flow with configurable behavior - auth_handler.flow = Mock( - get_user_token=AsyncMock(return_value=token_response), - _get_flow_state=AsyncMock( - # sign-in failed requires flow to be started - return_value=oauth_flow.FlowState( - flow_started=(flow_started or sign_in_failed) - ) - ), - begin_flow=AsyncMock(return_value=default_token), - # Mock flow continuation with optional failure simulation - continue_flow=AsyncMock( - return_value=None if sign_in_failed else default_token - ), - ) - - auth_handler.flow.flow_state = None diff --git a/tests/hosting_core/tools/testing_oauth.py b/tests/hosting_core/tools/testing_oauth.py deleted file mode 100644 index 1128f1ce..00000000 --- a/tests/hosting_core/tools/testing_oauth.py +++ /dev/null @@ -1,182 +0,0 @@ -from datetime import datetime - -from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag - -from tests._common.storage.utils import MockStoreItem - - -MS_APP_ID = "__ms_app_id" -CHANNEL_ID = "__channel_id" -USER_ID = "__user_id" -ABS_OAUTH_CONNECTION_NAME = "__connection_name" -RES_TOKEN = "__res_token" - -DEF_ARGS = { - "ms_app_id": MS_APP_ID, - "channel_id": CHANNEL_ID, - "user_id": USER_ID, - "connection": ABS_OAUTH_CONNECTION_NAME, -} - - -class FLOW_STATES: - - NOT_STARTED_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.NOT_STARTED, - attempts_remaining=1, - user_token="____", - expiration=datetime.now().timestamp() + 1000000, - ) - - STARTED_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.BEGIN, - attempts_remaining=1, - user_token="____", - expiration=datetime.now().timestamp() + 1000000, - ) - STARTED_FLOW_ONE_RETRY = FlowState( - **DEF_ARGS, - tag=FlowStateTag.BEGIN, - attempts_remaining=2, - user_token="____", - expiration=datetime.now().timestamp() + 1000000, - ) - ACTIVE_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.CONTINUE, - attempts_remaining=2, - user_token="__token", - expiration=datetime.now().timestamp() + 1000000, - ) - ACTIVE_FLOW_ONE_RETRY = FlowState( - **DEF_ARGS, - tag=FlowStateTag.CONTINUE, - attempts_remaining=1, - user_token="__token", - expiration=datetime.now().timestamp() + 1000000, - ) - ACTIVE_EXP_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.CONTINUE, - attempts_remaining=2, - user_token="__token", - expiration=datetime.now().timestamp(), - ) - COMPLETED_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.COMPLETE, - attempts_remaining=2, - user_token="test_token", - expiration=datetime.now().timestamp() + 1000000, - ) - FAIL_BY_ATTEMPTS_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.FAILURE, - attempts_remaining=0, - expiration=datetime.now().timestamp() + 1000000, - ) - - FAIL_BY_EXP_FLOW = FlowState( - **DEF_ARGS, tag=FlowStateTag.FAILURE, attempts_remaining=2, expiration=0 - ) - - @staticmethod - def clone_state_list(lst): - return [flow_state.model_copy() for flow_state in lst] - - @staticmethod - def ALL(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.STARTED_FLOW, - FLOW_STATES.STARTED_FLOW_ONE_RETRY, - FLOW_STATES.ACTIVE_FLOW, - FLOW_STATES.ACTIVE_FLOW_ONE_RETRY, - FLOW_STATES.ACTIVE_EXP_FLOW, - FLOW_STATES.COMPLETED_FLOW, - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, - FLOW_STATES.FAIL_BY_EXP_FLOW, - ] - ) - - @staticmethod - def FAILED(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.ACTIVE_EXP_FLOW, - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, - FLOW_STATES.FAIL_BY_EXP_FLOW, - ] - ) - - @staticmethod - def ACTIVE(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.STARTED_FLOW, - FLOW_STATES.STARTED_FLOW_ONE_RETRY, - FLOW_STATES.ACTIVE_FLOW, - FLOW_STATES.ACTIVE_FLOW_ONE_RETRY, - ] - ) - - @staticmethod - def INACTIVE(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.ACTIVE_EXP_FLOW, - FLOW_STATES.COMPLETED_FLOW, - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, - FLOW_STATES.FAIL_BY_EXP_FLOW, - ] - ) - - -def flow_key(channel_id, user_id, handler_id): - return f"auth/{channel_id}/{user_id}/{handler_id}" - - -def update_flow_state_handler(flow_state, handler): - flow_state = flow_state.model_copy() - flow_state.auth_handler_id = handler - return flow_state - - -STORAGE_SAMPLE_DICT = { - "user_id": MockStoreItem({"id": "123"}), - "session_id": MockStoreItem({"id": "abc"}), - flow_key("webchat", "Alice", "graph"): update_flow_state_handler( - FLOW_STATES.COMPLETED_FLOW.model_copy(), "graph" - ), - flow_key("webchat", "Alice", "github"): update_flow_state_handler( - FLOW_STATES.ACTIVE_FLOW_ONE_RETRY.model_copy(), "github" - ), - flow_key("teams", "Alice", "graph"): update_flow_state_handler( - FLOW_STATES.STARTED_FLOW.model_copy(), "graph" - ), - flow_key("webchat", "Bob", "graph"): update_flow_state_handler( - FLOW_STATES.ACTIVE_EXP_FLOW.model_copy(), "graph" - ), - flow_key("teams", "Bob", "slack"): update_flow_state_handler( - FLOW_STATES.STARTED_FLOW.model_copy(), "slack" - ), - flow_key("webchat", "Chuck", "github"): update_flow_state_handler( - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW.model_copy(), "github" - ), -} - - -def STORAGE_INIT_DATA(): - data = STORAGE_SAMPLE_DICT.copy() - for key, value in data.items(): - data[key] = value.model_copy() if isinstance(value, FlowState) else value - return data - - -def update_data_with_flow_state(data, channel_id, user_id, auth_handler_id, flow_state): - data = data.copy() - key = f"auth/{channel_id}/{user_id}/{auth_handler_id}" - data[key] = flow_state.model_copy() - return data