microsoft-agents-testing 1.5.0__py3-none-any.whl
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/__init__.py +8 -0
- microsoft_agents/testing/_defaults.py +15 -0
- microsoft_agents/testing/auth/__init__.py +8 -0
- microsoft_agents/testing/auth/_types.py +57 -0
- microsoft_agents/testing/auth/mock_user_token_client.py +373 -0
- microsoft_agents/testing/py.typed +0 -0
- microsoft_agents/testing/test_adapter.py +550 -0
- microsoft_agents/testing/test_flow.py +326 -0
- microsoft_agents/testing/type_def.py +9 -0
- microsoft_agents_testing-1.5.0.dist-info/METADATA +159 -0
- microsoft_agents_testing-1.5.0.dist-info/RECORD +14 -0
- microsoft_agents_testing-1.5.0.dist-info/WHEEL +5 -0
- microsoft_agents_testing-1.5.0.dist-info/licenses/LICENSE +21 -0
- microsoft_agents_testing-1.5.0.dist-info/top_level.txt +1 -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
|