microsoft-agents-testing 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microsoft_agents_testing-1.5.0/LICENSE +21 -0
- microsoft_agents_testing-1.5.0/MANIFEST.in +1 -0
- microsoft_agents_testing-1.5.0/PKG-INFO +159 -0
- microsoft_agents_testing-1.5.0/VERSION.txt +1 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/__init__.py +8 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/_defaults.py +15 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/auth/__init__.py +8 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/auth/_types.py +57 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/auth/mock_user_token_client.py +373 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/py.typed +0 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/test_adapter.py +550 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/test_flow.py +326 -0
- microsoft_agents_testing-1.5.0/microsoft_agents/testing/type_def.py +9 -0
- microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/PKG-INFO +159 -0
- microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/SOURCES.txt +20 -0
- microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/dependency_links.txt +1 -0
- microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/requires.txt +1 -0
- microsoft_agents_testing-1.5.0/microsoft_agents_testing.egg-info/top_level.txt +1 -0
- microsoft_agents_testing-1.5.0/pyproject.toml +28 -0
- microsoft_agents_testing-1.5.0/readme.md +138 -0
- microsoft_agents_testing-1.5.0/setup.cfg +4 -0
- microsoft_agents_testing-1.5.0/setup.py +17 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Microsoft Corporation.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include VERSION.txt
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: microsoft-agents-testing
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: Library for building agent tests using Microsoft Agents SDK
|
|
5
|
+
Author: Microsoft Corporation
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/microsoft/Agents
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: microsoft-agents-hosting-core==1.5.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
|
|
22
|
+
# Microsoft Agents Testing
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/microsoft-agents-testing/)
|
|
25
|
+
|
|
26
|
+
Testing utilities for the Microsoft 365 Agents SDK for Python. This package
|
|
27
|
+
provides an in-memory channel adapter, fluent conversation assertions, and a
|
|
28
|
+
mock user-token client so agent behavior can be tested without a live channel
|
|
29
|
+
or token service.
|
|
30
|
+
|
|
31
|
+
## Release Notes
|
|
32
|
+
|
|
33
|
+
<table style="width:100%">
|
|
34
|
+
<tr>
|
|
35
|
+
<th style="width:20%">Version</th>
|
|
36
|
+
<th style="width:20%">Date</th>
|
|
37
|
+
<th style="width:60%">Release Notes</th>
|
|
38
|
+
</tr>
|
|
39
|
+
<tr>
|
|
40
|
+
<td>1.5.0</td>
|
|
41
|
+
<td>2026-08-26</td>
|
|
42
|
+
<td>
|
|
43
|
+
<a href="https://github.com/microsoft/Agents-for-python/blob/main/changelog.md#microsoft-365-agents-sdk-for-python---release-notes-v150">
|
|
44
|
+
1.5.0 Release Notes
|
|
45
|
+
</a>
|
|
46
|
+
</td>
|
|
47
|
+
</tr>
|
|
48
|
+
</table>
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- **`TestAdapter`** runs activities through the normal middleware and agent
|
|
53
|
+
pipeline while capturing outgoing activities in memory.
|
|
54
|
+
- **`TestFlow`** provides fluent helpers for sending activities and asserting
|
|
55
|
+
replies in order.
|
|
56
|
+
- **`MockUserTokenClient`** supports stored tokens, magic codes, sign-out, token
|
|
57
|
+
status, and token-exchange scenarios.
|
|
58
|
+
- **Async-first** APIs work with the SDK's standard `TurnContext` and
|
|
59
|
+
`ChannelAdapter` abstractions.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install microsoft-agents-testing
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import pytest
|
|
71
|
+
|
|
72
|
+
from microsoft_agents.hosting.core import TurnContext
|
|
73
|
+
from microsoft_agents.testing import TestAdapter, TestFlow
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_echo_agent():
|
|
78
|
+
adapter = TestAdapter()
|
|
79
|
+
|
|
80
|
+
async def on_turn(context: TurnContext):
|
|
81
|
+
await context.send_activity(f"Echo: {context.activity.text}")
|
|
82
|
+
|
|
83
|
+
await (
|
|
84
|
+
TestFlow(adapter, on_turn)
|
|
85
|
+
.send("hello")
|
|
86
|
+
.assert_reply("Echo: hello")
|
|
87
|
+
.assert_no_more_replies()
|
|
88
|
+
.start_test()
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Reply Assertions
|
|
93
|
+
|
|
94
|
+
Replies can be matched by exact text, partial text, activity properties, or a
|
|
95
|
+
custom synchronous or asynchronous validator:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from microsoft_agents.activity import Activity, ActivityTypes
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_reply(reply: Activity):
|
|
102
|
+
assert reply.type == ActivityTypes.message
|
|
103
|
+
assert reply.text is not None
|
|
104
|
+
assert reply.text.startswith("Hello")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
await (
|
|
108
|
+
TestFlow(adapter, on_turn)
|
|
109
|
+
.send("hi")
|
|
110
|
+
.assert_reply_contains("Hello")
|
|
111
|
+
.send("details")
|
|
112
|
+
.assert_reply(validate_reply)
|
|
113
|
+
.start_test()
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`TestFlow` also supports conversation updates, typing indicators, delays,
|
|
118
|
+
mixed activity transcripts, and configurable reply timeouts.
|
|
119
|
+
|
|
120
|
+
## Testing OAuth Flows
|
|
121
|
+
|
|
122
|
+
`TestAdapter` uses `MockUserTokenClient` by default. Add a token to test an
|
|
123
|
+
already-authenticated user:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
adapter = TestAdapter(channel_id="msteams")
|
|
127
|
+
|
|
128
|
+
adapter.add_user_token(
|
|
129
|
+
connection_name="my-connection",
|
|
130
|
+
channel_id="msteams",
|
|
131
|
+
user_id="user1",
|
|
132
|
+
token="test-token",
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Token exchange can be configured in the same way:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
adapter.add_exchangeable_token(
|
|
140
|
+
connection_name="my-connection",
|
|
141
|
+
channel_id="msteams",
|
|
142
|
+
user_id="user1",
|
|
143
|
+
exchangeable_item="exchange-token",
|
|
144
|
+
token="exchanged-token",
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
These tokens are stored only in memory and are intended exclusively for tests.
|
|
149
|
+
|
|
150
|
+
## Scope
|
|
151
|
+
|
|
152
|
+
The adapter models the turn-processing boundary needed by unit tests. It does
|
|
153
|
+
not contact Connector Service, create real proactive conversations, or emulate
|
|
154
|
+
channel-specific delivery behavior.
|
|
155
|
+
|
|
156
|
+
## Related Packages
|
|
157
|
+
|
|
158
|
+
- [`microsoft-agents-hosting-core`](https://pypi.org/project/microsoft-agents-hosting-core/)
|
|
159
|
+
provides the agent runtime and abstractions used by this package.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.5.0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from .auth import MockUserTokenClient
|
|
5
|
+
from .test_adapter import TestAdapter
|
|
6
|
+
from .test_flow import TestFlow
|
|
7
|
+
|
|
8
|
+
__all__ = ["MockUserTokenClient", "TestAdapter", "TestFlow"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
_SERVICE_URL = "https://test.com"
|
|
5
|
+
|
|
6
|
+
_CONV_ID = "convo1"
|
|
7
|
+
_CONV_NAME = "Conversation 1"
|
|
8
|
+
|
|
9
|
+
_BOT_ID = "bot"
|
|
10
|
+
_BOT_NAME = "Bot"
|
|
11
|
+
|
|
12
|
+
_USER_ID = "user1"
|
|
13
|
+
_USER_NAME = "User 1"
|
|
14
|
+
|
|
15
|
+
_LOCALE = "en-US"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, eq=False)
|
|
9
|
+
class UserTokenKey:
|
|
10
|
+
"""A key that uniquely identifies a user token in the mock client."""
|
|
11
|
+
|
|
12
|
+
connection_name: str
|
|
13
|
+
user_id: str
|
|
14
|
+
channel_id: str
|
|
15
|
+
|
|
16
|
+
def __eq__(self, other: Any) -> bool:
|
|
17
|
+
return (
|
|
18
|
+
isinstance(other, UserTokenKey)
|
|
19
|
+
and self.connection_name.casefold() == other.connection_name.casefold()
|
|
20
|
+
and self.user_id.casefold() == other.user_id.casefold()
|
|
21
|
+
and self.channel_id.casefold() == other.channel_id.casefold()
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def __hash__(self) -> int:
|
|
25
|
+
return hash(
|
|
26
|
+
(
|
|
27
|
+
self.connection_name.casefold(),
|
|
28
|
+
self.user_id.casefold(),
|
|
29
|
+
self.channel_id.casefold(),
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, eq=False)
|
|
35
|
+
class ExchangeableTokenKey(UserTokenKey):
|
|
36
|
+
"""A key that uniquely identifies an exchangeable token in the mock client."""
|
|
37
|
+
|
|
38
|
+
exchangeable_item: str
|
|
39
|
+
|
|
40
|
+
def __eq__(self, other):
|
|
41
|
+
return (
|
|
42
|
+
super().__eq__(other)
|
|
43
|
+
and isinstance(other, ExchangeableTokenKey)
|
|
44
|
+
and self.exchangeable_item.casefold() == other.exchangeable_item.casefold()
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __hash__(self) -> int:
|
|
48
|
+
return hash((super().__hash__(), self.exchangeable_item.casefold()))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class TokenMagicCode:
|
|
53
|
+
"""A class that represents a magic code for a token."""
|
|
54
|
+
|
|
55
|
+
key: UserTokenKey
|
|
56
|
+
magic_code: str
|
|
57
|
+
user_token: str
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation. All rights reserved.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from microsoft_agents.activity import (
|
|
7
|
+
Activity,
|
|
8
|
+
SignInResource,
|
|
9
|
+
TokenExchangeRequest,
|
|
10
|
+
TokenExchangeResource,
|
|
11
|
+
TokenOrSignInResourceResponse,
|
|
12
|
+
TokenPostResource,
|
|
13
|
+
TokenResponse,
|
|
14
|
+
TokenStatus,
|
|
15
|
+
)
|
|
16
|
+
from microsoft_agents.hosting.core import UserTokenClientBase
|
|
17
|
+
|
|
18
|
+
from ._types import (
|
|
19
|
+
UserTokenKey,
|
|
20
|
+
ExchangeableTokenKey,
|
|
21
|
+
TokenMagicCode,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_RAISE_EXCEPTION = "_raise_exception"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MockUserTokenClient(UserTokenClientBase):
|
|
28
|
+
"""In-memory stand-in for ``UserTokenClientBase`` used by ``TestAdapter``.
|
|
29
|
+
|
|
30
|
+
The mock stores user tokens, magic-code tokens, and token-exchange results
|
|
31
|
+
in dictionaries keyed by connection, channel, and user. It lets tests drive
|
|
32
|
+
OAuth prompt and token-exchange flows without calling the Agents token
|
|
33
|
+
service. Sign-in resources are synthetic and deterministic enough for unit
|
|
34
|
+
tests, but they are not valid service URLs and no network call is made.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_user_tokens: dict[UserTokenKey, str]
|
|
38
|
+
_exchangable_tokens: dict[ExchangeableTokenKey, str]
|
|
39
|
+
_magic_codes: list[TokenMagicCode]
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
"""Create an empty in-memory token store."""
|
|
43
|
+
self._user_tokens = {}
|
|
44
|
+
self._exchangable_tokens = {}
|
|
45
|
+
self._magic_codes = []
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def agent_sign_in(self):
|
|
49
|
+
"""Agent sign-in operations are not modeled by this mock client."""
|
|
50
|
+
raise NotImplementedError()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def user_token(self):
|
|
54
|
+
"""Nested user-token operations are not exposed by this mock client."""
|
|
55
|
+
raise NotImplementedError()
|
|
56
|
+
|
|
57
|
+
def add_user_token(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
connection_name: str,
|
|
61
|
+
channel_id: str,
|
|
62
|
+
user_id: str,
|
|
63
|
+
token: str,
|
|
64
|
+
magic_code: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Add a fake user token that can later be retrieved by OAuth code.
|
|
67
|
+
|
|
68
|
+
Without a magic code, the token is returned immediately for the matching
|
|
69
|
+
connection, channel, and user. With a magic code, the token is held in a
|
|
70
|
+
separate one-time list and is promoted to the normal token store only
|
|
71
|
+
when :meth:`get_user_token` is called with the same code.
|
|
72
|
+
|
|
73
|
+
:param connection_name: The name of the connection.
|
|
74
|
+
:param channel_id: The channel ID.
|
|
75
|
+
:param user_id: The user ID.
|
|
76
|
+
:param token: The token to be added.
|
|
77
|
+
:param magic_code: An optional magic code associated with the token.
|
|
78
|
+
"""
|
|
79
|
+
key = UserTokenKey(
|
|
80
|
+
connection_name=connection_name, user_id=user_id, channel_id=channel_id
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if magic_code is None:
|
|
84
|
+
self._user_tokens[key] = token
|
|
85
|
+
else:
|
|
86
|
+
self._magic_codes.append(
|
|
87
|
+
TokenMagicCode(key=key, magic_code=magic_code, user_token=token)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def add_exchangeable_token(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
connection_name: str,
|
|
94
|
+
channel_id: str,
|
|
95
|
+
user_id: str,
|
|
96
|
+
exchangeable_item: str,
|
|
97
|
+
token: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Add a fake token-exchange result.
|
|
100
|
+
|
|
101
|
+
``exchangeable_item`` represents either the exchange request token or
|
|
102
|
+
URI. A later :meth:`exchange_token` call for the same connection,
|
|
103
|
+
channel, user, and item returns ``token``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
key = ExchangeableTokenKey(
|
|
107
|
+
connection_name=connection_name,
|
|
108
|
+
user_id=user_id,
|
|
109
|
+
channel_id=channel_id,
|
|
110
|
+
exchangeable_item=exchangeable_item,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self._exchangable_tokens[key] = token
|
|
114
|
+
|
|
115
|
+
def raise_on_exchange_request(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
connection_name: str,
|
|
119
|
+
channel_id: str,
|
|
120
|
+
user_id: str,
|
|
121
|
+
exchangeable_item: str,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Make a matching token-exchange request raise an exception.
|
|
124
|
+
|
|
125
|
+
This is a test-only way to simulate token service exchange failures
|
|
126
|
+
without a real service.
|
|
127
|
+
|
|
128
|
+
:param connection_name: The name of the connection.
|
|
129
|
+
:param channel_id: The channel ID.
|
|
130
|
+
:param user_id: The user ID.
|
|
131
|
+
:param exchangeable_item: The item to be exchanged.
|
|
132
|
+
"""
|
|
133
|
+
key = ExchangeableTokenKey(
|
|
134
|
+
connection_name=connection_name,
|
|
135
|
+
user_id=user_id,
|
|
136
|
+
channel_id=channel_id,
|
|
137
|
+
exchangeable_item=exchangeable_item,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self._exchangable_tokens[key] = _RAISE_EXCEPTION
|
|
141
|
+
|
|
142
|
+
async def get_user_token(
|
|
143
|
+
self,
|
|
144
|
+
user_id: str,
|
|
145
|
+
connection_name: str,
|
|
146
|
+
channel_id: str,
|
|
147
|
+
magic_code: str | None = None,
|
|
148
|
+
) -> TokenResponse:
|
|
149
|
+
"""Retrieve a fake user token from the in-memory store.
|
|
150
|
+
|
|
151
|
+
When ``magic_code`` matches a stored one-time code, the associated token
|
|
152
|
+
is moved into the normal token store before lookup. If no token is found,
|
|
153
|
+
an empty :class:`TokenResponse` is returned.
|
|
154
|
+
|
|
155
|
+
:param user_id: The user ID.
|
|
156
|
+
:param connection_name: The name of the connection.
|
|
157
|
+
:param channel_id: The channel ID.
|
|
158
|
+
:param magic_code: An optional magic code associated with the token.
|
|
159
|
+
:return: A TokenResponse containing the token if found, otherwise an empty TokenResponse.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
key = UserTokenKey(
|
|
163
|
+
connection_name=connection_name, user_id=user_id, channel_id=channel_id
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if magic_code is not None:
|
|
167
|
+
index = next(
|
|
168
|
+
(
|
|
169
|
+
i
|
|
170
|
+
for i, mc in enumerate(self._magic_codes)
|
|
171
|
+
if mc.key == key and mc.magic_code == magic_code
|
|
172
|
+
),
|
|
173
|
+
None,
|
|
174
|
+
)
|
|
175
|
+
if index is not None:
|
|
176
|
+
mc = self._magic_codes.pop(index)
|
|
177
|
+
self.add_user_token(
|
|
178
|
+
connection_name=connection_name,
|
|
179
|
+
channel_id=key.channel_id,
|
|
180
|
+
user_id=key.user_id,
|
|
181
|
+
token=mc.user_token,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if key in self._user_tokens:
|
|
185
|
+
token = self._user_tokens[key]
|
|
186
|
+
return TokenResponse(
|
|
187
|
+
token=token,
|
|
188
|
+
connection_name=connection_name,
|
|
189
|
+
)
|
|
190
|
+
return TokenResponse()
|
|
191
|
+
|
|
192
|
+
async def get_sign_in_resource(
|
|
193
|
+
self,
|
|
194
|
+
connection_name: str,
|
|
195
|
+
activity: Activity,
|
|
196
|
+
final_redirect: str | None = None,
|
|
197
|
+
) -> SignInResource:
|
|
198
|
+
"""Return a synthetic sign-in resource for tests.
|
|
199
|
+
|
|
200
|
+
The returned link and token-exchange resource are fake values derived
|
|
201
|
+
from the connection and activity. They are intended only to let tests
|
|
202
|
+
assert that a sign-in prompt would be sent.
|
|
203
|
+
|
|
204
|
+
:param connection_name: The name of the connection.
|
|
205
|
+
:param activity: The activity associated with the sign-in request.
|
|
206
|
+
:param final_redirect: An optional final redirect URL.
|
|
207
|
+
:return: A SignInResource containing the sign-in URL and other details.
|
|
208
|
+
"""
|
|
209
|
+
activity_channel_id = activity.channel_id if activity.channel_id else "unknown"
|
|
210
|
+
activity_recipient_id = (
|
|
211
|
+
activity.recipient.id if activity.recipient else "unknown"
|
|
212
|
+
)
|
|
213
|
+
return SignInResource(
|
|
214
|
+
sign_in_link=f"https://fake.com/oauthsignin/{connection_name}/{activity_channel_id}/{activity_recipient_id}",
|
|
215
|
+
token_exchange_resource=TokenExchangeResource(
|
|
216
|
+
id=uuid4().hex, uri=f"api://{connection_name}/resource"
|
|
217
|
+
),
|
|
218
|
+
token_post_resource=TokenPostResource(
|
|
219
|
+
sas_url=f"https://fake.com/oauthsignin/{connection_name}/token"
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def get_token_or_sign_in_resource(
|
|
224
|
+
self,
|
|
225
|
+
connection_name: str,
|
|
226
|
+
activity: Activity,
|
|
227
|
+
code: str | None = None,
|
|
228
|
+
final_redirect: str | None = None,
|
|
229
|
+
fwd_url: str | None = None,
|
|
230
|
+
) -> TokenOrSignInResourceResponse:
|
|
231
|
+
"""Return either a stored token or a synthetic sign-in resource.
|
|
232
|
+
|
|
233
|
+
This mirrors the token service shortcut used by OAuth prompts: if a
|
|
234
|
+
token is already available for the activity's user/channel, return it;
|
|
235
|
+
otherwise return a fake sign-in resource.
|
|
236
|
+
|
|
237
|
+
:param connection_name: The name of the connection.
|
|
238
|
+
:param activity: The activity associated with the request.
|
|
239
|
+
:param code: An optional magic code associated with the token.
|
|
240
|
+
:param final_redirect: An optional final redirect URL.
|
|
241
|
+
:param fwd_url: An optional forward URL.
|
|
242
|
+
:return: A TokenOrSignInResourceResponse containing either a token or a sign-in resource.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
if not activity.from_property or not activity.from_property.id:
|
|
246
|
+
raise ValueError("Activity must have a valid 'from' property with an 'id'.")
|
|
247
|
+
|
|
248
|
+
token_response = await self.get_user_token(
|
|
249
|
+
user_id=activity.from_property.id,
|
|
250
|
+
connection_name=connection_name,
|
|
251
|
+
channel_id=activity.channel_id if activity.channel_id else "unknown",
|
|
252
|
+
magic_code=code,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if token_response.token:
|
|
256
|
+
return TokenOrSignInResourceResponse(token_response=token_response)
|
|
257
|
+
|
|
258
|
+
return TokenOrSignInResourceResponse(
|
|
259
|
+
sign_in_resource=await self.get_sign_in_resource(
|
|
260
|
+
connection_name=connection_name,
|
|
261
|
+
activity=activity,
|
|
262
|
+
final_redirect=final_redirect,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def sign_out_user(
|
|
267
|
+
self, user_id: str, connection_name: str, channel_id: str
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Sign out a user by removing matching tokens from the mock store."""
|
|
270
|
+
keys_copy = list(self._user_tokens.keys())
|
|
271
|
+
for key in keys_copy:
|
|
272
|
+
if (
|
|
273
|
+
key.channel_id.casefold() == channel_id.casefold()
|
|
274
|
+
and key.user_id.casefold() == user_id.casefold()
|
|
275
|
+
and key.connection_name.casefold() == connection_name.casefold()
|
|
276
|
+
):
|
|
277
|
+
self._user_tokens.pop(key)
|
|
278
|
+
|
|
279
|
+
async def get_token_status(
|
|
280
|
+
self,
|
|
281
|
+
user_id: str,
|
|
282
|
+
channel_id: str,
|
|
283
|
+
include: str | None = None,
|
|
284
|
+
) -> list[TokenStatus]:
|
|
285
|
+
"""Return token status entries for stored tokens.
|
|
286
|
+
|
|
287
|
+
The mock reports a token as present when one exists in the in-memory
|
|
288
|
+
store for the requested user and channel. ``include`` filters by
|
|
289
|
+
connection name.
|
|
290
|
+
|
|
291
|
+
:param user_id: The user ID.
|
|
292
|
+
:param channel_id: The channel ID.
|
|
293
|
+
:param include: An optional comma-separated list of connection names to filter the results.
|
|
294
|
+
:return: A list of TokenStatus objects representing the token status for the user.
|
|
295
|
+
"""
|
|
296
|
+
include_filter = include.split(",") if include else None
|
|
297
|
+
return [
|
|
298
|
+
TokenStatus(
|
|
299
|
+
connection_name=key.connection_name,
|
|
300
|
+
has_token=True,
|
|
301
|
+
service_provider_display_name=key.connection_name,
|
|
302
|
+
)
|
|
303
|
+
for key in self._user_tokens.keys()
|
|
304
|
+
if key.user_id.casefold() == user_id.casefold()
|
|
305
|
+
and key.channel_id.casefold() == channel_id.casefold()
|
|
306
|
+
and (include_filter is None or key.connection_name in include_filter)
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
async def get_aad_tokens(
|
|
310
|
+
self,
|
|
311
|
+
user_id: str,
|
|
312
|
+
connection_name: str,
|
|
313
|
+
resource_urls: list[str],
|
|
314
|
+
channel_id: str,
|
|
315
|
+
) -> dict[str, TokenResponse]:
|
|
316
|
+
"""Return fake AAD tokens.
|
|
317
|
+
|
|
318
|
+
The Python testing mock currently does not model per-resource AAD token
|
|
319
|
+
acquisition, so this returns an empty mapping.
|
|
320
|
+
"""
|
|
321
|
+
return {}
|
|
322
|
+
|
|
323
|
+
async def exchange_token(
|
|
324
|
+
self,
|
|
325
|
+
user_id: str,
|
|
326
|
+
connection_name: str,
|
|
327
|
+
channel_id: str,
|
|
328
|
+
exchange_request: TokenExchangeRequest,
|
|
329
|
+
) -> TokenResponse:
|
|
330
|
+
"""Exchange a fake token or URI for a stored token response.
|
|
331
|
+
|
|
332
|
+
If :meth:`raise_on_exchange_request` configured the matching item to
|
|
333
|
+
fail, this raises an exception. If no matching item is registered, an
|
|
334
|
+
empty :class:`TokenResponse` is returned.
|
|
335
|
+
|
|
336
|
+
:param user_id: The user ID.
|
|
337
|
+
:param connection_name: The name of the connection.
|
|
338
|
+
:param channel_id: The channel ID.
|
|
339
|
+
:param exchange_request: The token exchange request containing the token or URI to be exchanged.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
exchangeable_value = exchange_request.token or exchange_request.uri
|
|
343
|
+
if not exchangeable_value:
|
|
344
|
+
raise ValueError(
|
|
345
|
+
"Either token or uri must be provided in the exchange request."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
key = ExchangeableTokenKey(
|
|
349
|
+
connection_name=connection_name,
|
|
350
|
+
user_id=user_id,
|
|
351
|
+
channel_id=channel_id,
|
|
352
|
+
exchangeable_item=exchangeable_value,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if key in self._exchangable_tokens:
|
|
356
|
+
token = self._exchangable_tokens[key]
|
|
357
|
+
if token == _RAISE_EXCEPTION:
|
|
358
|
+
raise Exception("Simulated exception during token exchange.")
|
|
359
|
+
|
|
360
|
+
return TokenResponse(
|
|
361
|
+
channel_id=channel_id, connection_name=connection_name, token=token
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return TokenResponse()
|
|
365
|
+
|
|
366
|
+
async def close(self) -> None:
|
|
367
|
+
"""Close the mock client.
|
|
368
|
+
|
|
369
|
+
The mock owns no network connections or other external resources, so
|
|
370
|
+
this is a no-op.
|
|
371
|
+
"""
|
|
372
|
+
# In this mock implementation, there's nothing to close.
|
|
373
|
+
pass
|
|
File without changes
|