Skip to content

Commit 022d25a

Browse files
authored
Add support for test user (#163)
1 parent eb4e417 commit 022d25a

File tree

6 files changed

+483
-8
lines changed

6 files changed

+483
-8
lines changed

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ You can create, update, delete or load tenants:
422422
# You can optionally set your own ID when creating a tenant
423423
descope_client.mgmt.tenant.create(
424424
name="My First Tenant",
425-
id="my-custom-id", # This is optional. If omitted
425+
id="my-custom-id", # This is optional.
426426
self_provisioning_domains=["domain.com"],
427427
)
428428

@@ -699,6 +699,55 @@ updated_jwt = descope_client.mgmt.jwt.update_jwt(
699699
)
700700
```
701701

702+
### Utils for your end to end (e2e) tests and integration tests
703+
704+
To ease your e2e tests, we exposed dedicated management methods,
705+
that way, you don't need to use 3rd party messaging services in order to receive sign-in/up Emails or SMS, and avoid the need of parsing the code and token from them.
706+
707+
```Python
708+
# User for test can be created, this user will be able to generate code/link without
709+
# the need of 3rd party messaging services.
710+
# Test user must have a loginId, other fields are optional.
711+
# Roles should be set directly if no tenants exist, otherwise set
712+
# on a per-tenant basis.
713+
descope_client.mgmt.user.create_test_user(
714+
login_id="[email protected]",
715+
716+
display_name="Desmond Copeland",
717+
user_tenants=[
718+
AssociatedTenant("my-tenant-id", ["role-name1"]),
719+
],
720+
)
721+
722+
# Now test user got created, and this user will be available until you delete it,
723+
# you can use any management operation for test user CRUD.
724+
# You can also delete all test users.
725+
descope_client.mgmt.user.delete_all_test_users()
726+
727+
# OTP code can be generated for test user, for example:
728+
resp = descope_client.mgmt.user.generate_otp_for_test_user(
729+
DeliveryMethod.EMAIL, "login-id"
730+
)
731+
code = resp["code"]
732+
# Now you can verify the code is valid (using descope_client.*.verify for example)
733+
734+
# Same as OTP, magic link can be generated for test user, for example:
735+
resp = descope_client.mgmt.user.generate_magic_link_for_test_user(
736+
DeliveryMethod.EMAIL, "login-id", ""
737+
)
738+
link = resp["link"]
739+
740+
# Enchanted link can be generated for test user, for example:
741+
resp = descope_client.mgmt.user.generate_enchanted_link_for_test_user(
742+
"login-id", ""
743+
)
744+
link = resp["link"]
745+
pending_ref = resp["pendingRef"]
746+
747+
# Note 1: The generate code/link functions, work only for test users, will not work for regular users.
748+
# Note 2: In case of testing sign-in / sign-up operations with test users, need to make sure to generate the code prior calling the sign-in / sign-up operations.
749+
```
750+
702751
## API Rate limits
703752

704753
Handle API rate limits by comparing the exception to the APIRateLimitExceeded exception, which includes the RateLimitParameters map with the key "Retry-After." This key indicates how many seconds until the next valid API call can take place. More information on Descope's rate limit is covered here: [Descope rate limit reference page](https://docs.descope.com/rate-limit)

descope/auth.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,19 @@ def get_login_id_by_method(method: DeliveryMethod, user: dict) -> Tuple[str, str
213213
400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}"
214214
)
215215

216+
@staticmethod
217+
def get_method_string(method: DeliveryMethod) -> str:
218+
if method is DeliveryMethod.EMAIL:
219+
return "email"
220+
elif method is DeliveryMethod.SMS:
221+
return "phone"
222+
elif method is DeliveryMethod.WHATSAPP:
223+
return "whatsapp"
224+
else:
225+
raise AuthException(
226+
400, ERROR_TYPE_INVALID_ARGUMENT, f"Unknown delivery method: {method}"
227+
)
228+
216229
@staticmethod
217230
def validate_email(email: str):
218231
if email == "":
@@ -309,7 +322,6 @@ def _validate_and_load_public_key(public_key) -> Tuple[str, jwt.PyJWK, str]:
309322
)
310323

311324
def _fetch_public_keys(self) -> None:
312-
313325
# This function called under mutex protection so no need to acquire it once again
314326
response = requests.get(
315327
f"{self.base_url}{EndpointsV2.public_key_path}/{self.project_id}",

descope/management/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class MgmtV1:
1212
user_create_path = "/v1/mgmt/user/create"
1313
user_update_path = "/v1/mgmt/user/update"
1414
user_delete_path = "/v1/mgmt/user/delete"
15+
user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all"
1516
user_load_path = "/v1/mgmt/user"
1617
users_search_path = "/v1/mgmt/user/search"
1718
user_update_status_path = "/v1/mgmt/user/update/status"
@@ -22,6 +23,9 @@ class MgmtV1:
2223
user_remove_role_path = "/v1/mgmt/user/update/role/remove"
2324
user_add_tenant_path = "/v1/mgmt/user/update/tenant/add"
2425
user_remove_tenant_path = "/v1/mgmt/user/update/tenant/remove"
26+
user_generate_otp_for_test_path = "/v1/mgmt/tests/generate/otp"
27+
user_generate_magic_link_for_test_path = "/v1/mgmt/tests/generate/magiclink"
28+
user_generate_enchanted_link_for_test_path = "/v1/mgmt/tests/generate/enchantedlink"
2529

2630
# access key
2731
access_key_create_path = "/v1/mgmt/accesskey/create"

descope/management/user.py

Lines changed: 183 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import List
22

33
from descope.auth import Auth
4+
from descope.common import DeliveryMethod
45
from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException
56
from descope.management.common import (
67
AssociatedTenant,
@@ -48,7 +49,62 @@ def create(
4849
response = self._auth.do_post(
4950
MgmtV1.user_create_path,
5051
User._compose_create_body(
51-
login_id, email, phone, display_name, role_names, user_tenants, False
52+
login_id,
53+
email,
54+
phone,
55+
display_name,
56+
role_names,
57+
user_tenants,
58+
False,
59+
False,
60+
),
61+
pswd=self._auth.management_key,
62+
)
63+
return response.json()
64+
65+
def create_test_user(
66+
self,
67+
login_id: str,
68+
email: str = None,
69+
phone: str = None,
70+
display_name: str = None,
71+
role_names: List[str] = [],
72+
user_tenants: List[AssociatedTenant] = [],
73+
) -> dict:
74+
"""
75+
Create a new test user.
76+
The login_id is required and will determine what the user will use to sign in.
77+
Make sure the login id is unique for test. All other fields are optional.
78+
79+
Args:
80+
login_id (str): user login ID.
81+
email (str): Optional user email address.
82+
phone (str): Optional user phone number.
83+
display_name (str): Optional user display name.
84+
role_names (List[str]): An optional list of the user's roles without tenant association. These roles are
85+
mutually exclusive with the `user_tenant` roles.
86+
user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are
87+
mutually exclusive with the general `role_names`.
88+
89+
Return value (dict):
90+
Return dict in the format
91+
{"user": {}}
92+
Containing the created test user information.
93+
94+
Raise:
95+
AuthException: raised if update operation fails
96+
"""
97+
response = self._auth.do_post(
98+
MgmtV1.user_create_path,
99+
User._compose_create_body(
100+
login_id,
101+
email,
102+
phone,
103+
display_name,
104+
role_names,
105+
user_tenants,
106+
False,
107+
True,
52108
),
53109
pswd=self._auth.management_key,
54110
)
@@ -77,7 +133,14 @@ def invite(
77133
response = self._auth.do_post(
78134
MgmtV1.user_create_path,
79135
User._compose_create_body(
80-
login_id, email, phone, display_name, role_names, user_tenants, True
136+
login_id,
137+
email,
138+
phone,
139+
display_name,
140+
role_names,
141+
user_tenants,
142+
True,
143+
False,
81144
),
82145
pswd=self._auth.management_key,
83146
)
@@ -112,7 +175,7 @@ def update(
112175
self._auth.do_post(
113176
MgmtV1.user_update_path,
114177
User._compose_update_body(
115-
login_id, email, phone, display_name, role_names, user_tenants
178+
login_id, email, phone, display_name, role_names, user_tenants, False
116179
),
117180
pswd=self._auth.management_key,
118181
)
@@ -136,6 +199,21 @@ def delete(
136199
pswd=self._auth.management_key,
137200
)
138201

202+
def delete_all_test_users(
203+
self,
204+
):
205+
"""
206+
Delete all test users in the project. IMPORTANT: This action is irreversible. Use carefully.
207+
208+
Raise:
209+
AuthException: raised if creation operation fails
210+
"""
211+
self._auth.do_post(
212+
MgmtV1.user_delete_all_test_users_path,
213+
{},
214+
pswd=self._auth.management_key,
215+
)
216+
139217
def load(
140218
self,
141219
login_id: str,
@@ -193,6 +271,8 @@ def search_all(
193271
role_names: List[str] = [],
194272
limit: int = 0,
195273
page: int = 0,
274+
test_users_only: bool = False,
275+
with_test_user: bool = False,
196276
) -> dict:
197277
"""
198278
Search all users.
@@ -202,6 +282,8 @@ def search_all(
202282
role_names (List[str]): Optional list of role names to filter by
203283
limit (int): Optional limit of the number of users returned. Leave empty for default.
204284
page (int): Optional pagination control. Pages start at 0 and must be non-negative.
285+
test_users_only (bool): Optional filter only test users.
286+
with_test_user (bool): Optional include test users in search.
205287
206288
Return value (dict):
207289
Return dict in the format
@@ -228,6 +310,8 @@ def search_all(
228310
"roleNames": role_names,
229311
"limit": limit,
230312
"page": page,
313+
"testUsersOnly": test_users_only,
314+
"withTestUser": with_test_user,
231315
},
232316
pswd=self._auth.management_key,
233317
)
@@ -536,6 +620,98 @@ def remove_tenant_roles(
536620
)
537621
return response.json()
538622

623+
def generate_otp_for_test_user(
624+
self,
625+
method: DeliveryMethod,
626+
login_id: str,
627+
) -> dict:
628+
"""
629+
Generate OTP for the given login ID of a test user.
630+
This is useful when running tests and don't want to use 3rd party messaging services.
631+
632+
Args:
633+
method (DeliveryMethod): The method to use for "delivering" the OTP verification code to the user, for example
634+
EMAIL, SMS, or WHATSAPP
635+
login_id (str): The login ID of the test user being validated.
636+
637+
Return value (dict):
638+
Return dict in the format
639+
{"code": "", "loginId": ""}
640+
Containing the code for the login (exactly as it sent via Email or SMS).
641+
642+
Raise:
643+
AuthException: raised if the operation fails
644+
"""
645+
response = self._auth.do_post(
646+
MgmtV1.user_generate_otp_for_test_path,
647+
{"loginId": login_id, "deliveryMethod": Auth.get_method_string(method)},
648+
pswd=self._auth.management_key,
649+
)
650+
return response.json()
651+
652+
def generate_magic_link_for_test_user(
653+
self,
654+
method: DeliveryMethod,
655+
login_id: str,
656+
uri: str,
657+
) -> dict:
658+
"""
659+
Generate Magic Link for the given login ID of a test user.
660+
This is useful when running tests and don't want to use 3rd party messaging services.
661+
662+
Args:
663+
method (DeliveryMethod): The method to use for "delivering" the verification magic link to the user, for example
664+
EMAIL, SMS, or WHATSAPP
665+
login_id (str): The login ID of the test user being validated.
666+
uri (str): Optional redirect uri which will be used instead of any global configuration.
667+
668+
Return value (dict):
669+
Return dict in the format
670+
{"link": "", "loginId": ""}
671+
Containing the magic link for the login (exactly as it sent via Email or SMS).
672+
673+
Raise:
674+
AuthException: raised if the operation fails
675+
"""
676+
response = self._auth.do_post(
677+
MgmtV1.user_generate_magic_link_for_test_path,
678+
{
679+
"loginId": login_id,
680+
"deliveryMethod": Auth.get_method_string(method),
681+
"URI": uri,
682+
},
683+
pswd=self._auth.management_key,
684+
)
685+
return response.json()
686+
687+
def generate_enchanted_link_for_test_user(
688+
self,
689+
login_id: str,
690+
uri: str,
691+
) -> dict:
692+
"""
693+
Generate Enchanted Link for the given login ID of a test user.
694+
This is useful when running tests and don't want to use 3rd party messaging services.
695+
696+
Args:
697+
login_id (str): The login ID of the test user being validated.
698+
uri (str): Optional redirect uri which will be used instead of any global configuration.
699+
700+
Return value (dict):
701+
Return dict in the format
702+
{"link": "", "loginId": "", "pendingRef": ""}
703+
Containing the enchanted link for the login (exactly as it sent via Email or SMS) and pendingRef.
704+
705+
Raise:
706+
AuthException: raised if the operation fails
707+
"""
708+
response = self._auth.do_post(
709+
MgmtV1.user_generate_enchanted_link_for_test_path,
710+
{"loginId": login_id, "URI": uri},
711+
pswd=self._auth.management_key,
712+
)
713+
return response.json()
714+
539715
@staticmethod
540716
def _compose_create_body(
541717
login_id: str,
@@ -545,9 +721,10 @@ def _compose_create_body(
545721
role_names: List[str],
546722
user_tenants: List[AssociatedTenant],
547723
invite: bool,
724+
test: bool,
548725
) -> dict:
549726
body = User._compose_update_body(
550-
login_id, email, phone, display_name, role_names, user_tenants
727+
login_id, email, phone, display_name, role_names, user_tenants, test
551728
)
552729
body["invite"] = invite
553730
return body
@@ -560,6 +737,7 @@ def _compose_update_body(
560737
display_name: str,
561738
role_names: List[str],
562739
user_tenants: List[AssociatedTenant],
740+
test: bool,
563741
) -> dict:
564742
return {
565743
"loginId": login_id,
@@ -568,4 +746,5 @@ def _compose_update_body(
568746
"displayName": display_name,
569747
"roleNames": role_names,
570748
"userTenants": associated_tenants_to_dict(user_tenants),
749+
"test": test,
571750
}

0 commit comments

Comments
 (0)