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.
@@ -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,8 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from .mock_user_token_client import MockUserTokenClient
5
+
6
+ __all__ = [
7
+ "MockUserTokenClient",
8
+ ]
@@ -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