pytest-clerk 4.0.1__tar.gz → 4.0.3__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.
- {pytest_clerk-4.0.1 → pytest_clerk-4.0.3}/PKG-INFO +5 -12
- {pytest_clerk-4.0.1 → pytest_clerk-4.0.3}/pyproject.toml +19 -14
- {pytest_clerk-4.0.1 → pytest_clerk-4.0.3}/pytest_clerk/clerk.py +173 -2
- {pytest_clerk-4.0.1 → pytest_clerk-4.0.3}/LICENSE +0 -0
- {pytest_clerk-4.0.1 → pytest_clerk-4.0.3}/README.md +0 -0
- {pytest_clerk-4.0.1 → pytest_clerk-4.0.3}/pytest_clerk/__init__.py +0 -0
|
@@ -1,26 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: pytest-clerk
|
|
3
|
-
Version: 4.0.
|
|
3
|
+
Version: 4.0.3
|
|
4
4
|
Summary: A set of pytest fixtures to help with integration testing with Clerk.
|
|
5
|
-
Home-page: https://gitlab.com/munipal-oss/pytest-clerk
|
|
6
5
|
License: MIT
|
|
7
6
|
Author: Ryan Causey
|
|
8
7
|
Author-email: ryan.causey@munipal.io
|
|
9
8
|
Requires-Python: >=3.10,<4.0
|
|
10
9
|
Classifier: Framework :: Pytest
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
10
|
Provides-Extra: aws
|
|
18
|
-
Requires-Dist: httpx (>=0.28.0,<0.
|
|
11
|
+
Requires-Dist: httpx (>=0.28.0,<1.0.0)
|
|
12
|
+
Requires-Dist: limits (>=4.0.1,<5.0.0)
|
|
19
13
|
Requires-Dist: pytest (>=8.0.0,<9.0.0)
|
|
20
|
-
Requires-Dist: pytest-aws-fixtures (>=3.0.0,<4.0
|
|
14
|
+
Requires-Dist: pytest-aws-fixtures (>=3.0.0,<4.0) ; extra == "aws"
|
|
21
15
|
Requires-Dist: python-decouple (>=3.0,<4.0)
|
|
22
16
|
Requires-Dist: tenacity (>=9.0.0,<10.0.0)
|
|
23
|
-
Project-URL: Repository, https://gitlab.com/munipal-oss/pytest-clerk
|
|
24
17
|
Description-Content-Type: text/markdown
|
|
25
18
|
|
|
26
19
|
# Pytest Clerk
|
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
[
|
|
1
|
+
[project]
|
|
2
2
|
name = "pytest-clerk"
|
|
3
|
-
version = "4.0.
|
|
3
|
+
version = "4.0.3"
|
|
4
4
|
description = "A set of pytest fixtures to help with integration testing with Clerk."
|
|
5
|
-
authors = ["Ryan Causey <ryan.causey@munipal.io>"]
|
|
6
5
|
license = "MIT"
|
|
7
6
|
readme = "README.md"
|
|
8
7
|
repository = "https://gitlab.com/munipal-oss/pytest-clerk"
|
|
9
8
|
homepage = "https://gitlab.com/munipal-oss/pytest-clerk"
|
|
10
9
|
packages = [{include = "pytest_clerk"}]
|
|
11
10
|
classifiers = ["Framework :: Pytest"]
|
|
11
|
+
requires-python = ">=3.10,<4.0"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pytest >=8.0.0,<9.0.0",
|
|
14
|
+
"python-decouple >=3.0,<4.0",
|
|
15
|
+
"tenacity >=9.0.0,<10.0.0",
|
|
16
|
+
"httpx >=0.28.0,<1.0.0",
|
|
17
|
+
"limits (>=4.0.1,<5.0.0)",
|
|
18
|
+
]
|
|
12
19
|
|
|
13
|
-
[
|
|
14
|
-
|
|
15
|
-
pytest
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
aws = [
|
|
22
|
+
"pytest-aws-fixtures >=3.0.0,<4.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[[project.authors]]
|
|
26
|
+
name = "Ryan Causey"
|
|
27
|
+
email = "ryan.causey@munipal.io"
|
|
20
28
|
|
|
21
29
|
[tool.poetry.group.dev.dependencies]
|
|
22
30
|
prospector = "^1.10.2"
|
|
@@ -24,10 +32,7 @@ black = "^24.0.0"
|
|
|
24
32
|
isort = "^5.12.0"
|
|
25
33
|
pre-commit = "^4.0.0"
|
|
26
34
|
|
|
27
|
-
[
|
|
28
|
-
aws = ["pytest-aws-fixtures"]
|
|
29
|
-
|
|
30
|
-
[tool.poetry.plugins."pytest11"]
|
|
35
|
+
[project.entry-points."pytest11"]
|
|
31
36
|
pytest_clerk = "pytest_clerk.clerk"
|
|
32
37
|
|
|
33
38
|
[build-system]
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
1
3
|
from contextlib import suppress
|
|
4
|
+
from time import sleep
|
|
2
5
|
|
|
3
6
|
import httpx
|
|
4
7
|
import pytest
|
|
5
8
|
from decouple import UndefinedValueError, config
|
|
9
|
+
from limits import RateLimitItemPerSecond, storage, strategies
|
|
6
10
|
from tenacity import retry, retry_if_exception, wait_random_exponential
|
|
7
11
|
|
|
8
12
|
retryable_status_codes = (
|
|
@@ -13,6 +17,165 @@ retryable_status_codes = (
|
|
|
13
17
|
httpx.codes.GATEWAY_TIMEOUT,
|
|
14
18
|
)
|
|
15
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClerkRateLimiter:
|
|
24
|
+
"""This class manages all of the rate limits for the various API endpoints of Clerk
|
|
25
|
+
for both the front end and back end APIs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
"""Initialize all of the rate limits."""
|
|
30
|
+
# Use an instance of an object to specify the "no limits" default. This is so
|
|
31
|
+
# that we don't pass any `is None` checks when the default is specified as no
|
|
32
|
+
# limits.
|
|
33
|
+
self.no_limit = object()
|
|
34
|
+
self.backend_rate_limits = {
|
|
35
|
+
# 19 requests per 10 seconds.
|
|
36
|
+
"POST": {r"/v1/users": RateLimitItemPerSecond(amount=19, multiples=10)},
|
|
37
|
+
# No rate limits.
|
|
38
|
+
"GET": {r"/v1/jwks": self.no_limit},
|
|
39
|
+
# 99 requests per 10 seconds.
|
|
40
|
+
"DEFAULT": RateLimitItemPerSecond(amount=99, multiples=10),
|
|
41
|
+
}
|
|
42
|
+
self.frontend_rate_limits = {
|
|
43
|
+
"POST": {
|
|
44
|
+
# 2 requests per 10 seconds.
|
|
45
|
+
r"/v1/client/sign_ins/(?P<sign_in_id>.*?)/attempt_first_factor": RateLimitItemPerSecond(
|
|
46
|
+
amount=2, multiples=10
|
|
47
|
+
),
|
|
48
|
+
# 2 requests per 10 seconds.
|
|
49
|
+
r"/v1/client/sign_ins/(?P<sign_in_id>.*?)/attempt_second_factor": RateLimitItemPerSecond(
|
|
50
|
+
amount=2, multiples=10
|
|
51
|
+
),
|
|
52
|
+
# 2 requests per 10 seconds.
|
|
53
|
+
r"/v1/client/sign_ups/(?P<sign_up_id>.*?)/attempt_verification": RateLimitItemPerSecond(
|
|
54
|
+
amount=2, multiples=10
|
|
55
|
+
),
|
|
56
|
+
# 4 requests per 10 seconds.
|
|
57
|
+
r"/v1/client/sign_ins": RateLimitItemPerSecond(amount=4, multiples=10),
|
|
58
|
+
# 4 requests per 10 seconds.
|
|
59
|
+
r"/v1/client/sign_ups": RateLimitItemPerSecond(amount=4, multiples=10),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
self.storage = storage.MemoryStorage()
|
|
63
|
+
self.strategy = strategies.FixedWindowRateLimiter(self.storage)
|
|
64
|
+
|
|
65
|
+
def get_rate_limit_item(self, request, rate_limits):
|
|
66
|
+
"""Return the found rate limit item and namespace to use when checking the rate
|
|
67
|
+
limit for this request.
|
|
68
|
+
|
|
69
|
+
The returned namespace should be used as the unique identifier in order to group
|
|
70
|
+
the various rate limit checks.
|
|
71
|
+
|
|
72
|
+
If there is no rate limit specified, the rate limit item will be `None`. If
|
|
73
|
+
there is a rate limit specified, but it is unlimited, the rate limit item will
|
|
74
|
+
be `self.no_limit`.
|
|
75
|
+
"""
|
|
76
|
+
path = request.url.path
|
|
77
|
+
method = request.method
|
|
78
|
+
host = request.url.host
|
|
79
|
+
|
|
80
|
+
logger.debug(
|
|
81
|
+
"Searching for rate limit for method %s, host %s, and path %s.",
|
|
82
|
+
method,
|
|
83
|
+
host,
|
|
84
|
+
path,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
rate_limit_item = None
|
|
88
|
+
namespace = None
|
|
89
|
+
|
|
90
|
+
# Check all the configured rate limits for the method.
|
|
91
|
+
method_limits = rate_limits.get(method, {})
|
|
92
|
+
for path_regex, rate_limit in method_limits.items():
|
|
93
|
+
logger.debug(
|
|
94
|
+
"Checking if regex %s and rate limit %s matches for method %s, host %s,"
|
|
95
|
+
" and path %s.",
|
|
96
|
+
path_regex,
|
|
97
|
+
rate_limit,
|
|
98
|
+
method,
|
|
99
|
+
host,
|
|
100
|
+
path,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if re.match(path_regex, path):
|
|
104
|
+
rate_limit_item = rate_limit
|
|
105
|
+
namespace = f"{host}:{path}:{method}"
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
# If we didn't get a limit for the method + path, try the default for that
|
|
109
|
+
# method.
|
|
110
|
+
if rate_limit_item is None:
|
|
111
|
+
logger.debug(
|
|
112
|
+
"No rate limit found for method %s, host %s, and path %s. Trying"
|
|
113
|
+
" default rate limit for method %s and host %s.",
|
|
114
|
+
method,
|
|
115
|
+
host,
|
|
116
|
+
path,
|
|
117
|
+
method,
|
|
118
|
+
host,
|
|
119
|
+
)
|
|
120
|
+
rate_limit_item = method_limits.get("DEFAULT")
|
|
121
|
+
namespace = f"{host}:{method}"
|
|
122
|
+
|
|
123
|
+
# If there was no default limit for the method, try the overall default.
|
|
124
|
+
if rate_limit_item is None:
|
|
125
|
+
logger.debug(
|
|
126
|
+
"No rate limit found for method %s and host %s. Trying global default.",
|
|
127
|
+
method,
|
|
128
|
+
host,
|
|
129
|
+
)
|
|
130
|
+
rate_limit_item = rate_limits.get("DEFAULT")
|
|
131
|
+
namespace = host
|
|
132
|
+
|
|
133
|
+
logger.debug(
|
|
134
|
+
"Found rate limit %s with namespace %s for method %s, host %s, and path"
|
|
135
|
+
" %s.",
|
|
136
|
+
rate_limit_item,
|
|
137
|
+
namespace,
|
|
138
|
+
method,
|
|
139
|
+
host,
|
|
140
|
+
path,
|
|
141
|
+
)
|
|
142
|
+
return rate_limit_item, namespace
|
|
143
|
+
|
|
144
|
+
def rate_limit_hook(self, request):
|
|
145
|
+
"""Check the requeste URL and hit the appropriate rate limit."""
|
|
146
|
+
# Get the rate limit for the request.
|
|
147
|
+
if request.url.host == "api.clerk.com":
|
|
148
|
+
logger.debug("Checking back end rate limits for request %s.", request)
|
|
149
|
+
rate_limit_item, namespace = self.get_rate_limit_item(
|
|
150
|
+
request=request, rate_limits=self.backend_rate_limits
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
logger.debug("Checking front end rate limits for request %s.", request)
|
|
154
|
+
rate_limit_item, namespace = self.get_rate_limit_item(
|
|
155
|
+
request=request, rate_limits=self.frontend_rate_limits
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# If the rate limit was specified as unlimited, do nothing.
|
|
159
|
+
if rate_limit_item is self.no_limit:
|
|
160
|
+
logger.debug("The rate limit is unlimited for request %s.", request)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# If there is no rate limit, do nothing.
|
|
164
|
+
if rate_limit_item is None:
|
|
165
|
+
logger.debug("There was no rate limit found for request %s.", request)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
logger.debug("The rate limit is %s for request %s.", rate_limit_item, request)
|
|
169
|
+
|
|
170
|
+
# Check if we hit the rate limit.
|
|
171
|
+
while not self.strategy.hit(rate_limit_item, namespace):
|
|
172
|
+
logger.info(
|
|
173
|
+
"Hit rate limit for namespace %s while making request %s.",
|
|
174
|
+
namespace,
|
|
175
|
+
request,
|
|
176
|
+
)
|
|
177
|
+
sleep(1)
|
|
178
|
+
|
|
16
179
|
|
|
17
180
|
def retry_predicate(exception):
|
|
18
181
|
"""Return whether to retry. This depends on getting an exception with a response
|
|
@@ -24,6 +187,12 @@ def retry_predicate(exception):
|
|
|
24
187
|
)
|
|
25
188
|
|
|
26
189
|
|
|
190
|
+
@pytest.fixture(scope="session")
|
|
191
|
+
def clerk_rate_limiter():
|
|
192
|
+
"""Return an instance of the Clerk rate limiter for use with HTTPX request hooks."""
|
|
193
|
+
return ClerkRateLimiter()
|
|
194
|
+
|
|
195
|
+
|
|
27
196
|
@pytest.fixture(scope="session")
|
|
28
197
|
def clerk_secret_key(request):
|
|
29
198
|
"""Retrieve the clerk secret key to use for the test.
|
|
@@ -54,7 +223,7 @@ def clerk_secret_key(request):
|
|
|
54
223
|
|
|
55
224
|
|
|
56
225
|
@pytest.fixture(scope="session")
|
|
57
|
-
def clerk_backend_httpx_client(clerk_secret_key):
|
|
226
|
+
def clerk_backend_httpx_client(clerk_secret_key, clerk_rate_limiter):
|
|
58
227
|
"""A fixture that creates a HTTPX Client instance with the required backend Clerk
|
|
59
228
|
Authorization headers set and the correct Clerk backend API base URL.
|
|
60
229
|
|
|
@@ -64,6 +233,7 @@ def clerk_backend_httpx_client(clerk_secret_key):
|
|
|
64
233
|
client = httpx.Client(
|
|
65
234
|
headers={"Authorization": f"Bearer {clerk_secret_key}"},
|
|
66
235
|
base_url="https://api.clerk.com/v1",
|
|
236
|
+
event_hooks={"request": [clerk_rate_limiter.rate_limit_hook]},
|
|
67
237
|
)
|
|
68
238
|
|
|
69
239
|
yield client
|
|
@@ -89,7 +259,7 @@ def clerk_frontend_api_url():
|
|
|
89
259
|
|
|
90
260
|
|
|
91
261
|
@pytest.fixture(scope="session")
|
|
92
|
-
def clerk_frontend_httpx_client(clerk_frontend_api_url):
|
|
262
|
+
def clerk_frontend_httpx_client(clerk_frontend_api_url, clerk_rate_limiter):
|
|
93
263
|
"""This fixture returns a function that creates an HTTPX Client instance with the
|
|
94
264
|
required frontend Clerk Authorization parameters set and the correct Clerk frontend
|
|
95
265
|
API base URL.
|
|
@@ -106,6 +276,7 @@ def clerk_frontend_httpx_client(clerk_frontend_api_url):
|
|
|
106
276
|
client = httpx.Client(
|
|
107
277
|
params={"__dev_session": result.json()["token"]},
|
|
108
278
|
base_url=f"{clerk_frontend_api_url}/v1",
|
|
279
|
+
event_hooks={"request": [clerk_rate_limiter.rate_limit_hook]},
|
|
109
280
|
)
|
|
110
281
|
|
|
111
282
|
yield client
|
|
File without changes
|
|
File without changes
|
|
File without changes
|