FlaskTester 1.3__tar.gz → 2.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.
- {FlaskTester-1.3 → FlaskTester-2.0}/FlaskTester.egg-info/PKG-INFO +26 -6
- {FlaskTester-1.3 → FlaskTester-2.0}/FlaskTester.egg-info/requires.txt +1 -1
- {FlaskTester-1.3 → FlaskTester-2.0}/FlaskTester.py +121 -34
- {FlaskTester-1.3 → FlaskTester-2.0}/PKG-INFO +26 -6
- {FlaskTester-1.3 → FlaskTester-2.0}/README.md +24 -4
- {FlaskTester-1.3 → FlaskTester-2.0}/pyproject.toml +2 -2
- {FlaskTester-1.3 → FlaskTester-2.0}/tests/test.py +73 -41
- {FlaskTester-1.3 → FlaskTester-2.0}/FlaskTester.egg-info/SOURCES.txt +0 -0
- {FlaskTester-1.3 → FlaskTester-2.0}/FlaskTester.egg-info/dependency_links.txt +0 -0
- {FlaskTester-1.3 → FlaskTester-2.0}/FlaskTester.egg-info/top_level.txt +0 -0
- {FlaskTester-1.3 → FlaskTester-2.0}/LICENSE +0 -0
- {FlaskTester-1.3 → FlaskTester-2.0}/MANIFEST.in +0 -0
- {FlaskTester-1.3 → FlaskTester-2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: FlaskTester
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0
|
|
4
4
|
Summary: Pytest fixtures for Flask internal and external authenticated tests
|
|
5
5
|
Author-email: Fabien Coelho <flask.tester@coelho.net>
|
|
6
6
|
License: CC0
|
|
@@ -32,7 +32,7 @@ Requires-Dist: twine; extra == "dev"
|
|
|
32
32
|
Requires-Dist: wheel; extra == "dev"
|
|
33
33
|
Requires-Dist: types-flask; extra == "dev"
|
|
34
34
|
Requires-Dist: types-requests; extra == "dev"
|
|
35
|
-
Requires-Dist: FlaskSimpleAuth; extra == "dev"
|
|
35
|
+
Requires-Dist: FlaskSimpleAuth>=29.4; extra == "dev"
|
|
36
36
|
Requires-Dist: passlib; extra == "dev"
|
|
37
37
|
Provides-Extra: doc
|
|
38
38
|
Requires-Dist: sphinx; extra == "doc"
|
|
@@ -65,7 +65,7 @@ achieved through environment variables.
|
|
|
65
65
|
Install package with `pip install FlaskTester` or equivalent.
|
|
66
66
|
|
|
67
67
|
The following test creates a local fixture with 2 users identified by a
|
|
68
|
-
password, and retrieves
|
|
68
|
+
password, and retrieves tokens for both users using a `/login` route
|
|
69
69
|
provided by the application.
|
|
70
70
|
It then proceeds to run authenticated requests against the `/admin` route.
|
|
71
71
|
|
|
@@ -86,6 +86,9 @@ def app(ft_client):
|
|
|
86
86
|
res = ft_client.post("/login", login="hobbes", auth="param", status=201)
|
|
87
87
|
assert res.is_json
|
|
88
88
|
ft_client.setToken("hobbes", res.json["token"])
|
|
89
|
+
# also set a cookie
|
|
90
|
+
ft_client.setCookie("hobbes", "lang", "fr_FR")
|
|
91
|
+
ft_client.setCookie("calvin", "lang", "en_EN")
|
|
89
92
|
# return working client
|
|
90
93
|
yield ft_client
|
|
91
94
|
|
|
@@ -155,6 +158,7 @@ The package provides two fixtures:
|
|
|
155
158
|
The fixture has 3 main methods:
|
|
156
159
|
- `setPass` to associate a password to a user, set to _None_ to remove credential.
|
|
157
160
|
- `setToken` to associate a token to a user, set to _None_ to remove credential.
|
|
161
|
+
- `setCookie` to add a cookie to a user, set value to _None_ to remove cookie.
|
|
158
162
|
- `setAuth` to add authentication data to a request `kwargs`.
|
|
159
163
|
|
|
160
164
|
- `ft_client` for app testing, which depends on the previous fixture, plus
|
|
@@ -175,7 +179,7 @@ The package provides two fixtures:
|
|
|
175
179
|
The fixture then provides test methods to issue test requests against a Flask application:
|
|
176
180
|
- `request` generic request with `login`, `auth`, `status` end `content` extensions.
|
|
177
181
|
- `get post put patch delete` methods with `login`, `auth` and `status` extensions.
|
|
178
|
-
Moreover, `setPass` and `
|
|
182
|
+
Moreover, `setPass`, `setToken` and `setCookie` are forwarded to the internal authenticator.
|
|
179
183
|
|
|
180
184
|
Authenticator environment variables can be set from the pytest Python test file by
|
|
181
185
|
assigning them through `os.environ`.
|
|
@@ -225,14 +229,30 @@ The implementation of these fixtures is based on five classes plus exceptions:
|
|
|
225
229
|
|
|
226
230
|
## License
|
|
227
231
|
|
|
228
|
-
This code is
|
|
232
|
+
This code is [Public Domain](https://creativecommons.org/publicdomain/zero/1.0/).
|
|
233
|
+
|
|
234
|
+
All software has bug, this is software, hence… Beware that you may lose your
|
|
235
|
+
hairs or your friends because of it. If you like it, feel free to send a
|
|
236
|
+
postcard to the author.
|
|
237
|
+
|
|
238
|
+
## Versions
|
|
229
239
|
|
|
230
240
|
Packages are distributed from [PyPI](https://pypi.org/project/FlaskTester/),
|
|
231
241
|
[sources](https://github.com/zx80/flask-tester) are available on GitHub,
|
|
232
242
|
see also the [documentation](https://zx80.github.io/flask-tester/),
|
|
233
243
|
please report any [issues](https://github.com/zx80/flask-tester/issues).
|
|
234
244
|
|
|
235
|
-
|
|
245
|
+
### 2.0 on 2024-03-23
|
|
246
|
+
|
|
247
|
+
Add support for cookies.
|
|
248
|
+
Improved documentation and code.
|
|
249
|
+
Improved tests.
|
|
250
|
+
|
|
251
|
+
### 1.4 on 2024-03-19
|
|
252
|
+
|
|
253
|
+
Test expected assert failures.
|
|
254
|
+
Improved API documentation.
|
|
255
|
+
Keep first found app.
|
|
236
256
|
|
|
237
257
|
### 1.3 on 2024-03-16
|
|
238
258
|
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
"""FlaskTester - Pytest fixtures for Flask authenticated internal and external tests.
|
|
2
|
+
|
|
3
|
+
Pytest: PYTEST_DONT_REWRITE
|
|
4
|
+
"""
|
|
5
|
+
|
|
1
6
|
import os
|
|
2
7
|
import io
|
|
3
8
|
import re
|
|
@@ -55,6 +60,7 @@ class Authenticator:
|
|
|
55
60
|
_TOKEN_SCHEMES = {"bearer", "header", "cookie", "tparam"}
|
|
56
61
|
_PASS_SCHEMES = {"basic", "param"}
|
|
57
62
|
|
|
63
|
+
# all supported authentication schemes
|
|
58
64
|
_AUTH_SCHEMES = {"fake"}
|
|
59
65
|
_AUTH_SCHEMES.update(_TOKEN_SCHEMES)
|
|
60
66
|
_AUTH_SCHEMES.update(_PASS_SCHEMES)
|
|
@@ -91,21 +97,25 @@ class Authenticator:
|
|
|
91
97
|
self._cookie = cookie
|
|
92
98
|
self._tparam = tparam
|
|
93
99
|
|
|
94
|
-
# password and token credentials
|
|
100
|
+
# password and token credentials, cookies
|
|
95
101
|
self._passes: dict[str, str] = {}
|
|
96
102
|
self._tokens: dict[str, str] = {}
|
|
103
|
+
self._cookies: dict[str, dict[str, str]] = {}
|
|
97
104
|
|
|
98
|
-
def _set(self,
|
|
105
|
+
def _set(self, key: str, val: str|None, store: dict[str, str]):
|
|
99
106
|
"""Set a key/value in a directory, with None for delete."""
|
|
100
107
|
if val is None:
|
|
101
|
-
if
|
|
102
|
-
del store[
|
|
108
|
+
if key in store:
|
|
109
|
+
del store[key]
|
|
103
110
|
else:
|
|
104
111
|
assert isinstance(val, str)
|
|
105
|
-
store[
|
|
112
|
+
store[key] = val
|
|
106
113
|
|
|
107
114
|
def setPass(self, login: str, pw: str|None):
|
|
108
|
-
"""Associate a password to a user.
|
|
115
|
+
"""Associate a password to a user.
|
|
116
|
+
|
|
117
|
+
Set to *None* to remove the password entry.
|
|
118
|
+
"""
|
|
109
119
|
if not self._has_pass:
|
|
110
120
|
raise AuthError("cannot set password, no password scheme allowed")
|
|
111
121
|
self._set(login, pw, self._passes)
|
|
@@ -117,11 +127,20 @@ class Authenticator:
|
|
|
117
127
|
self.setPass(login, pw)
|
|
118
128
|
|
|
119
129
|
def setToken(self, login: str, token: str|None):
|
|
120
|
-
"""Associate a token to a user.
|
|
130
|
+
"""Associate a token to a user.
|
|
131
|
+
|
|
132
|
+
Set to *None* to remove the token entry.
|
|
133
|
+
"""
|
|
121
134
|
if not self._has_token:
|
|
122
135
|
raise AuthError("cannot set token, no token scheme allowed")
|
|
123
136
|
self._set(login, token, self._tokens)
|
|
124
137
|
|
|
138
|
+
def setCookie(self, login: str, name: str, val: str|None = None):
|
|
139
|
+
"""Associate a cookie and its value to a login, *None* to remove."""
|
|
140
|
+
if login not in self._cookies:
|
|
141
|
+
self._cookies[login] = {}
|
|
142
|
+
self._set(name, val, self._cookies[login])
|
|
143
|
+
|
|
125
144
|
def _param(self, kwargs: dict[str, Any], key: str, val: Any):
|
|
126
145
|
"""Add request parameter to "json" or "data"."""
|
|
127
146
|
|
|
@@ -138,12 +157,16 @@ class Authenticator:
|
|
|
138
157
|
"""Whether to try this authentication scheme."""
|
|
139
158
|
return auth in (None, scheme) and scheme in self._allow
|
|
140
159
|
|
|
141
|
-
def setAuth(self, login: str|None, kwargs: dict[str, Any], auth: str|None = None):
|
|
160
|
+
def setAuth(self, login: str|None, kwargs: dict[str, Any], cookies: dict[str, str], auth: str|None = None):
|
|
142
161
|
"""Set request authentication.
|
|
143
162
|
|
|
144
163
|
- login: login target, None means no authentication
|
|
145
164
|
- kwargs: request parameters
|
|
146
|
-
-
|
|
165
|
+
- cookies: request cookies
|
|
166
|
+
- auth: authentication method, default is None
|
|
167
|
+
|
|
168
|
+
The default behavior is to try allowed schemes: tokens first,
|
|
169
|
+
then password, then fake.
|
|
147
170
|
"""
|
|
148
171
|
|
|
149
172
|
log.debug(f"setAuth: login={login} auth={auth} allow={self._allow}")
|
|
@@ -151,29 +174,29 @@ class Authenticator:
|
|
|
151
174
|
if login is None: # not needed
|
|
152
175
|
return
|
|
153
176
|
|
|
177
|
+
cookies.update(self._cookies.get(login, {}))
|
|
178
|
+
|
|
154
179
|
if auth is not None:
|
|
155
180
|
if auth not in self._AUTH_SCHEMES:
|
|
156
181
|
raise AuthError(f"unexpected auth: {auth}")
|
|
157
182
|
if auth not in self._allow:
|
|
158
183
|
raise AuthError(f"auth is not allowed: {auth}")
|
|
159
184
|
|
|
185
|
+
headers = kwargs.get("headers", {})
|
|
186
|
+
|
|
160
187
|
# use token if available and allowed
|
|
161
188
|
if login in self._tokens and auth in (None, "bearer", "header", "cookie", "tparam"):
|
|
162
189
|
|
|
163
190
|
token = self._tokens[login]
|
|
164
191
|
|
|
165
|
-
if "headers" not in kwargs:
|
|
166
|
-
kwargs["headers"] = {}
|
|
167
|
-
|
|
168
192
|
if self._try_auth(auth, "bearer"):
|
|
169
|
-
|
|
193
|
+
headers["Authorization"] = self._bearer + " " + token
|
|
170
194
|
elif self._try_auth(auth, "header"):
|
|
171
|
-
|
|
195
|
+
headers[self._header] = token
|
|
172
196
|
elif self._try_auth(auth, "tparam"):
|
|
173
197
|
self._param(kwargs, self._tparam, token)
|
|
174
198
|
elif self._try_auth(auth, "cookie"):
|
|
175
|
-
|
|
176
|
-
kwargs["headers"]["Cookie"] = self._cookie + "=" + token
|
|
199
|
+
cookies[self._cookie] = token
|
|
177
200
|
else:
|
|
178
201
|
raise AuthError(f"no token carrier: login={login} auth={auth} allow={self._allow}")
|
|
179
202
|
|
|
@@ -195,6 +218,9 @@ class Authenticator:
|
|
|
195
218
|
|
|
196
219
|
raise AuthError(f"no authentication for login={login} auth={auth} allow={self._allow}")
|
|
197
220
|
|
|
221
|
+
if headers: # put headers back if needed
|
|
222
|
+
kwargs["headers"] = headers
|
|
223
|
+
|
|
198
224
|
|
|
199
225
|
class RequestFlaskResponse:
|
|
200
226
|
"""Wrapper to return a Flask-looking response from a request response.
|
|
@@ -233,16 +259,17 @@ class RequestFlaskResponse:
|
|
|
233
259
|
|
|
234
260
|
|
|
235
261
|
class Client:
|
|
236
|
-
"""Common class for flask authenticated testing.
|
|
262
|
+
"""Common (partial) class for flask authenticated testing.
|
|
237
263
|
|
|
238
264
|
Constructor parameters:
|
|
239
265
|
|
|
240
266
|
- ``auth`` authenticator
|
|
241
|
-
- ``default_login`` if
|
|
267
|
+
- ``default_login`` if ``login`` is not set.
|
|
242
268
|
"""
|
|
243
269
|
|
|
244
270
|
def __init__(self, auth: Authenticator, default_login: str|None = None):
|
|
245
271
|
self._auth = auth
|
|
272
|
+
self._cookies: dict[str, dict[str, str]] = {} # login -> name -> value
|
|
246
273
|
self._default_login = default_login
|
|
247
274
|
|
|
248
275
|
def setToken(self, login: str, token: str|None):
|
|
@@ -253,7 +280,11 @@ class Client:
|
|
|
253
280
|
"""Associate a password to a login, *None* to remove."""
|
|
254
281
|
self._auth.setPass(login, password)
|
|
255
282
|
|
|
256
|
-
def
|
|
283
|
+
def setCookie(self, login: str, name: str, val: str|None):
|
|
284
|
+
"""Associate a cookie to a login, *None* name to remove."""
|
|
285
|
+
self._auth.setCookie(login, name, val)
|
|
286
|
+
|
|
287
|
+
def _request(self, method: str, path: str, cookies: dict[str, str], **kwargs):
|
|
257
288
|
"""Run a request and return response."""
|
|
258
289
|
raise NotImplementedError()
|
|
259
290
|
|
|
@@ -269,7 +300,7 @@ class Client:
|
|
|
269
300
|
|
|
270
301
|
- ``status``: expected HTTP status, *None* to skip status check
|
|
271
302
|
- ``login``: authenticated user, use **explicit** *None* to skip
|
|
272
|
-
- ``auth``: authentication scheme to use
|
|
303
|
+
- ``auth``: authentication scheme to use instead of default behavior
|
|
273
304
|
- ``**kwargs``: more request parameters (headers, data, json…)
|
|
274
305
|
"""
|
|
275
306
|
|
|
@@ -279,13 +310,18 @@ class Client:
|
|
|
279
310
|
else: # if unset, use default
|
|
280
311
|
login = self._default_login
|
|
281
312
|
|
|
282
|
-
|
|
283
|
-
|
|
313
|
+
cookies: dict[str, str] = {}
|
|
314
|
+
if "cookies" in kwargs:
|
|
315
|
+
cookies.update(kwargs["cookies"])
|
|
316
|
+
del kwargs["cookies"]
|
|
317
|
+
|
|
318
|
+
self._auth.setAuth(login, kwargs, cookies, auth=auth)
|
|
319
|
+
res = self._request(method, path, cookies, **kwargs) # type: ignore
|
|
284
320
|
|
|
285
321
|
if status is not None:
|
|
286
|
-
if res.status_code != status: #
|
|
322
|
+
if res.status_code != status: # show error before aborting
|
|
287
323
|
log.error(f"bad {status} result: {res.status_code} {res.text[:512]}")
|
|
288
|
-
assert res.status_code == status
|
|
324
|
+
assert res.status_code == status, f"unexpected status {res.status_code}, expecting {status}"
|
|
289
325
|
|
|
290
326
|
return res
|
|
291
327
|
|
|
@@ -330,9 +366,9 @@ class Client:
|
|
|
330
366
|
|
|
331
367
|
# check contents
|
|
332
368
|
if content is not None:
|
|
333
|
-
if not re.search(content, res.text, re.DOTALL):
|
|
369
|
+
if not re.search(content, res.text, re.DOTALL):
|
|
334
370
|
log.error(f"cannot find {content} in {res.text}")
|
|
335
|
-
assert False, "content not found"
|
|
371
|
+
assert False, f"expected content {content} not found in {res.text}"
|
|
336
372
|
|
|
337
373
|
return res
|
|
338
374
|
|
|
@@ -344,7 +380,7 @@ class RequestClient(Client):
|
|
|
344
380
|
|
|
345
381
|
- ``auth`` authenticator
|
|
346
382
|
- ``base_url`` target server
|
|
347
|
-
- ``default_login`` if
|
|
383
|
+
- ``default_login`` if ``login`` is not set.
|
|
348
384
|
"""
|
|
349
385
|
|
|
350
386
|
def __init__(self, auth: Authenticator, base_url: str, default_login=None):
|
|
@@ -354,7 +390,7 @@ class RequestClient(Client):
|
|
|
354
390
|
from requests import Session
|
|
355
391
|
self._requests = Session()
|
|
356
392
|
|
|
357
|
-
def _request(self, method: str, path: str, **kwargs):
|
|
393
|
+
def _request(self, method: str, path: str, cookies: dict[str, str], **kwargs):
|
|
358
394
|
"""Actual request handling."""
|
|
359
395
|
|
|
360
396
|
if "data" in kwargs:
|
|
@@ -378,7 +414,7 @@ class RequestClient(Client):
|
|
|
378
414
|
# sanity
|
|
379
415
|
assert not (files and "json" in kwargs), "cannot mix file upload and json?"
|
|
380
416
|
|
|
381
|
-
res = self._requests.request(method, self._base_url + path, **kwargs)
|
|
417
|
+
res = self._requests.request(method, self._base_url + path, cookies=cookies, **kwargs)
|
|
382
418
|
|
|
383
419
|
return RequestFlaskResponse(res)
|
|
384
420
|
|
|
@@ -389,22 +425,61 @@ class FlaskClient(Client):
|
|
|
389
425
|
Constructor parameters:
|
|
390
426
|
|
|
391
427
|
- ``auth`` authenticator
|
|
392
|
-
- ``client`` Flask ``test_client``
|
|
393
|
-
- ``default_login`` if
|
|
428
|
+
- ``client`` Flask actual ``test_client``
|
|
429
|
+
- ``default_login`` if ``login`` is not set.
|
|
430
|
+
|
|
431
|
+
Note: this client handles `cookies`.
|
|
394
432
|
"""
|
|
395
433
|
|
|
396
434
|
def __init__(self, auth: Authenticator, client, default_login=None):
|
|
397
435
|
super().__init__(auth, default_login)
|
|
398
436
|
self._client = client
|
|
437
|
+
self._cookie_names: set[str] = set() # FIXME all encountered cookies for cleanup :-/
|
|
399
438
|
|
|
400
|
-
def _request(self, method: str, path: str, **kwargs):
|
|
439
|
+
def _request(self, method: str, path: str, cookies: dict[str, str], **kwargs):
|
|
401
440
|
"""Actual request handling."""
|
|
441
|
+
|
|
442
|
+
# hack to cleanup client state :-/
|
|
443
|
+
for cookie in self._cookie_names:
|
|
444
|
+
self._client.delete_cookie(cookie)
|
|
445
|
+
|
|
446
|
+
for cookie, val in cookies.items():
|
|
447
|
+
if cookie not in self._cookie_names:
|
|
448
|
+
self._cookie_names.add(cookie)
|
|
449
|
+
self._client.set_cookie(cookie, val)
|
|
450
|
+
|
|
402
451
|
return self._client.open(method=method, path=path, **kwargs)
|
|
403
452
|
|
|
404
453
|
|
|
405
454
|
@pytest.fixture
|
|
406
455
|
def ft_authenticator():
|
|
407
|
-
"""Pytest Fixture: ft_authenticator.
|
|
456
|
+
"""Pytest Fixture: ft_authenticator.
|
|
457
|
+
|
|
458
|
+
Environment variables:
|
|
459
|
+
|
|
460
|
+
- ``FLASK_TESTER_LOG_LEVEL``: package log level in
|
|
461
|
+
``DEBUG INFO WARNING ERROR CRITICAL NOSET``.
|
|
462
|
+
Default is ``NOTSET``.
|
|
463
|
+
- ``FLASK_TESTER_ALLOW``: allowed space-separated authentication schemes, in
|
|
464
|
+
``basic param bearer header cookie tparam fake``.
|
|
465
|
+
Default is ``bearer basic param``.
|
|
466
|
+
- ``FLASK_TESTER_USER``: user login parameter for ``param`` authentication.
|
|
467
|
+
Default is ``USER``.
|
|
468
|
+
- ``FLASK_TESTER_PASS``: user password parameter for ``param`` authentication.
|
|
469
|
+
Default is ``PASS``.
|
|
470
|
+
- ``FLASK_TESTER_LOGIN``: user login parameter for ``fake`` authentication.
|
|
471
|
+
Default is ``LOGIN``.
|
|
472
|
+
- ``FLASK_TESTER_BEARER``: bearer name for *token* authentication.
|
|
473
|
+
Default is ``Bearer``.
|
|
474
|
+
- ``FLASK_TESTER_HEADER``: header name for *token* authentication.
|
|
475
|
+
Default is ``Auth``.
|
|
476
|
+
- ``FLASK_TESTER_COOKIE``: cookie name for *token* authentication.
|
|
477
|
+
Default is ``auth``.
|
|
478
|
+
- ``FLASK_TESTER_TPARAM``: parameter for *token* authentication.
|
|
479
|
+
Default is ``AUTH``.
|
|
480
|
+
- ``FLASK_TESTER_AUTH``: initial comma-separated list of *login:password*.
|
|
481
|
+
Default is not set.
|
|
482
|
+
"""
|
|
408
483
|
|
|
409
484
|
level = os.environ.get("FLASK_TESTER_LOG_LEVEL", "NOTSET")
|
|
410
485
|
log.setLevel(logging.DEBUG if level == "DEBUG" else
|
|
@@ -436,7 +511,18 @@ def ft_authenticator():
|
|
|
436
511
|
|
|
437
512
|
@pytest.fixture
|
|
438
513
|
def ft_client(ft_authenticator):
|
|
439
|
-
"""Pytest Fixture: ft_client.
|
|
514
|
+
"""Pytest Fixture: ft_client.
|
|
515
|
+
|
|
516
|
+
Target environment variable, one **must** be defined:
|
|
517
|
+
|
|
518
|
+
- ``FLASK_TESTER_URL``: application HTTP base URL.
|
|
519
|
+
- ``FLASK_TESTER_APP``: Flask application, eg ``app:create_app``.
|
|
520
|
+
|
|
521
|
+
Other environment variable:
|
|
522
|
+
|
|
523
|
+
- ``FLASK_TESTER_DEFAULT``: Default client login, default is *None* for no
|
|
524
|
+
default.
|
|
525
|
+
"""
|
|
440
526
|
|
|
441
527
|
default_login = os.environ.get("FLASK_TESTER_DEFAULT", None)
|
|
442
528
|
client: Client
|
|
@@ -461,6 +547,7 @@ def ft_client(ft_authenticator):
|
|
|
461
547
|
app = getattr(pkg, name)
|
|
462
548
|
if callable(app) and not hasattr(app, "test_client"):
|
|
463
549
|
app = app()
|
|
550
|
+
break
|
|
464
551
|
if not app: # pragma: no cover
|
|
465
552
|
raise FlaskTesterError(f"cannot find Flask app in {pkg_name}")
|
|
466
553
|
client = FlaskClient(ft_authenticator, app.test_client(), default_login)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: FlaskTester
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0
|
|
4
4
|
Summary: Pytest fixtures for Flask internal and external authenticated tests
|
|
5
5
|
Author-email: Fabien Coelho <flask.tester@coelho.net>
|
|
6
6
|
License: CC0
|
|
@@ -32,7 +32,7 @@ Requires-Dist: twine; extra == "dev"
|
|
|
32
32
|
Requires-Dist: wheel; extra == "dev"
|
|
33
33
|
Requires-Dist: types-flask; extra == "dev"
|
|
34
34
|
Requires-Dist: types-requests; extra == "dev"
|
|
35
|
-
Requires-Dist: FlaskSimpleAuth; extra == "dev"
|
|
35
|
+
Requires-Dist: FlaskSimpleAuth>=29.4; extra == "dev"
|
|
36
36
|
Requires-Dist: passlib; extra == "dev"
|
|
37
37
|
Provides-Extra: doc
|
|
38
38
|
Requires-Dist: sphinx; extra == "doc"
|
|
@@ -65,7 +65,7 @@ achieved through environment variables.
|
|
|
65
65
|
Install package with `pip install FlaskTester` or equivalent.
|
|
66
66
|
|
|
67
67
|
The following test creates a local fixture with 2 users identified by a
|
|
68
|
-
password, and retrieves
|
|
68
|
+
password, and retrieves tokens for both users using a `/login` route
|
|
69
69
|
provided by the application.
|
|
70
70
|
It then proceeds to run authenticated requests against the `/admin` route.
|
|
71
71
|
|
|
@@ -86,6 +86,9 @@ def app(ft_client):
|
|
|
86
86
|
res = ft_client.post("/login", login="hobbes", auth="param", status=201)
|
|
87
87
|
assert res.is_json
|
|
88
88
|
ft_client.setToken("hobbes", res.json["token"])
|
|
89
|
+
# also set a cookie
|
|
90
|
+
ft_client.setCookie("hobbes", "lang", "fr_FR")
|
|
91
|
+
ft_client.setCookie("calvin", "lang", "en_EN")
|
|
89
92
|
# return working client
|
|
90
93
|
yield ft_client
|
|
91
94
|
|
|
@@ -155,6 +158,7 @@ The package provides two fixtures:
|
|
|
155
158
|
The fixture has 3 main methods:
|
|
156
159
|
- `setPass` to associate a password to a user, set to _None_ to remove credential.
|
|
157
160
|
- `setToken` to associate a token to a user, set to _None_ to remove credential.
|
|
161
|
+
- `setCookie` to add a cookie to a user, set value to _None_ to remove cookie.
|
|
158
162
|
- `setAuth` to add authentication data to a request `kwargs`.
|
|
159
163
|
|
|
160
164
|
- `ft_client` for app testing, which depends on the previous fixture, plus
|
|
@@ -175,7 +179,7 @@ The package provides two fixtures:
|
|
|
175
179
|
The fixture then provides test methods to issue test requests against a Flask application:
|
|
176
180
|
- `request` generic request with `login`, `auth`, `status` end `content` extensions.
|
|
177
181
|
- `get post put patch delete` methods with `login`, `auth` and `status` extensions.
|
|
178
|
-
Moreover, `setPass` and `
|
|
182
|
+
Moreover, `setPass`, `setToken` and `setCookie` are forwarded to the internal authenticator.
|
|
179
183
|
|
|
180
184
|
Authenticator environment variables can be set from the pytest Python test file by
|
|
181
185
|
assigning them through `os.environ`.
|
|
@@ -225,14 +229,30 @@ The implementation of these fixtures is based on five classes plus exceptions:
|
|
|
225
229
|
|
|
226
230
|
## License
|
|
227
231
|
|
|
228
|
-
This code is
|
|
232
|
+
This code is [Public Domain](https://creativecommons.org/publicdomain/zero/1.0/).
|
|
233
|
+
|
|
234
|
+
All software has bug, this is software, hence… Beware that you may lose your
|
|
235
|
+
hairs or your friends because of it. If you like it, feel free to send a
|
|
236
|
+
postcard to the author.
|
|
237
|
+
|
|
238
|
+
## Versions
|
|
229
239
|
|
|
230
240
|
Packages are distributed from [PyPI](https://pypi.org/project/FlaskTester/),
|
|
231
241
|
[sources](https://github.com/zx80/flask-tester) are available on GitHub,
|
|
232
242
|
see also the [documentation](https://zx80.github.io/flask-tester/),
|
|
233
243
|
please report any [issues](https://github.com/zx80/flask-tester/issues).
|
|
234
244
|
|
|
235
|
-
|
|
245
|
+
### 2.0 on 2024-03-23
|
|
246
|
+
|
|
247
|
+
Add support for cookies.
|
|
248
|
+
Improved documentation and code.
|
|
249
|
+
Improved tests.
|
|
250
|
+
|
|
251
|
+
### 1.4 on 2024-03-19
|
|
252
|
+
|
|
253
|
+
Test expected assert failures.
|
|
254
|
+
Improved API documentation.
|
|
255
|
+
Keep first found app.
|
|
236
256
|
|
|
237
257
|
### 1.3 on 2024-03-16
|
|
238
258
|
|
|
@@ -22,7 +22,7 @@ achieved through environment variables.
|
|
|
22
22
|
Install package with `pip install FlaskTester` or equivalent.
|
|
23
23
|
|
|
24
24
|
The following test creates a local fixture with 2 users identified by a
|
|
25
|
-
password, and retrieves
|
|
25
|
+
password, and retrieves tokens for both users using a `/login` route
|
|
26
26
|
provided by the application.
|
|
27
27
|
It then proceeds to run authenticated requests against the `/admin` route.
|
|
28
28
|
|
|
@@ -43,6 +43,9 @@ def app(ft_client):
|
|
|
43
43
|
res = ft_client.post("/login", login="hobbes", auth="param", status=201)
|
|
44
44
|
assert res.is_json
|
|
45
45
|
ft_client.setToken("hobbes", res.json["token"])
|
|
46
|
+
# also set a cookie
|
|
47
|
+
ft_client.setCookie("hobbes", "lang", "fr_FR")
|
|
48
|
+
ft_client.setCookie("calvin", "lang", "en_EN")
|
|
46
49
|
# return working client
|
|
47
50
|
yield ft_client
|
|
48
51
|
|
|
@@ -112,6 +115,7 @@ The package provides two fixtures:
|
|
|
112
115
|
The fixture has 3 main methods:
|
|
113
116
|
- `setPass` to associate a password to a user, set to _None_ to remove credential.
|
|
114
117
|
- `setToken` to associate a token to a user, set to _None_ to remove credential.
|
|
118
|
+
- `setCookie` to add a cookie to a user, set value to _None_ to remove cookie.
|
|
115
119
|
- `setAuth` to add authentication data to a request `kwargs`.
|
|
116
120
|
|
|
117
121
|
- `ft_client` for app testing, which depends on the previous fixture, plus
|
|
@@ -132,7 +136,7 @@ The package provides two fixtures:
|
|
|
132
136
|
The fixture then provides test methods to issue test requests against a Flask application:
|
|
133
137
|
- `request` generic request with `login`, `auth`, `status` end `content` extensions.
|
|
134
138
|
- `get post put patch delete` methods with `login`, `auth` and `status` extensions.
|
|
135
|
-
Moreover, `setPass` and `
|
|
139
|
+
Moreover, `setPass`, `setToken` and `setCookie` are forwarded to the internal authenticator.
|
|
136
140
|
|
|
137
141
|
Authenticator environment variables can be set from the pytest Python test file by
|
|
138
142
|
assigning them through `os.environ`.
|
|
@@ -182,14 +186,30 @@ The implementation of these fixtures is based on five classes plus exceptions:
|
|
|
182
186
|
|
|
183
187
|
## License
|
|
184
188
|
|
|
185
|
-
This code is
|
|
189
|
+
This code is [Public Domain](https://creativecommons.org/publicdomain/zero/1.0/).
|
|
190
|
+
|
|
191
|
+
All software has bug, this is software, hence… Beware that you may lose your
|
|
192
|
+
hairs or your friends because of it. If you like it, feel free to send a
|
|
193
|
+
postcard to the author.
|
|
194
|
+
|
|
195
|
+
## Versions
|
|
186
196
|
|
|
187
197
|
Packages are distributed from [PyPI](https://pypi.org/project/FlaskTester/),
|
|
188
198
|
[sources](https://github.com/zx80/flask-tester) are available on GitHub,
|
|
189
199
|
see also the [documentation](https://zx80.github.io/flask-tester/),
|
|
190
200
|
please report any [issues](https://github.com/zx80/flask-tester/issues).
|
|
191
201
|
|
|
192
|
-
|
|
202
|
+
### 2.0 on 2024-03-23
|
|
203
|
+
|
|
204
|
+
Add support for cookies.
|
|
205
|
+
Improved documentation and code.
|
|
206
|
+
Improved tests.
|
|
207
|
+
|
|
208
|
+
### 1.4 on 2024-03-19
|
|
209
|
+
|
|
210
|
+
Test expected assert failures.
|
|
211
|
+
Improved API documentation.
|
|
212
|
+
Keep first found app.
|
|
193
213
|
|
|
194
214
|
### 1.3 on 2024-03-16
|
|
195
215
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "FlaskTester"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0"
|
|
8
8
|
authors = [ { name = "Fabien Coelho", email = "flask.tester@coelho.net" } ]
|
|
9
9
|
description = "Pytest fixtures for Flask internal and external authenticated tests"
|
|
10
10
|
readme = "README.md"
|
|
@@ -33,7 +33,7 @@ package = "https://pypi.org/project/FlaskTester/"
|
|
|
33
33
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
35
|
# various dev tools
|
|
36
|
-
dev = ["mypy", "pyright", "ruff", "coverage", "pymarkdownlnt!=0.9.5", "build", "twine", "wheel", "types-flask", "types-requests", "FlaskSimpleAuth", "passlib"]
|
|
36
|
+
dev = ["mypy", "pyright", "ruff", "coverage", "pymarkdownlnt!=0.9.5", "build", "twine", "wheel", "types-flask", "types-requests", "FlaskSimpleAuth>=29.4", "passlib"]
|
|
37
37
|
# documentation generation
|
|
38
38
|
doc = ["sphinx", "sphinx_rtd_theme", "sphinx-autoapi", "sphinx-lint", "myst_parser"]
|
|
39
39
|
|
|
@@ -10,6 +10,8 @@ import io
|
|
|
10
10
|
import logging
|
|
11
11
|
|
|
12
12
|
logging.basicConfig(level=logging.INFO)
|
|
13
|
+
# logging.basicConfig(level=logging.DEBUG)
|
|
14
|
+
# log = logging.getLogger("test")
|
|
13
15
|
|
|
14
16
|
# set authn for ft_authenticator
|
|
15
17
|
os.environ.update(
|
|
@@ -20,6 +22,7 @@ os.environ.update(
|
|
|
20
22
|
def test_sanity():
|
|
21
23
|
# must provide url or package of Flask application to test
|
|
22
24
|
assert "FLASK_TESTER_URL" in os.environ or "FLASK_TESTER_APP" in os.environ
|
|
25
|
+
# log.debug(f"TEST_SEED={os.environ.get('TEST_SEED')}")
|
|
23
26
|
|
|
24
27
|
# example from README.md
|
|
25
28
|
@pytest.fixture
|
|
@@ -34,6 +37,9 @@ def app(ft_client):
|
|
|
34
37
|
res = ft_client.post("/login", login="hobbes", auth="param", status=201)
|
|
35
38
|
assert res.is_json
|
|
36
39
|
ft_client.setToken("hobbes", res.json["token"])
|
|
40
|
+
# also set a cookie
|
|
41
|
+
ft_client.setCookie("hobbes", "lang", "fr_FR")
|
|
42
|
+
ft_client.setCookie("calvin", "lang", "en_EN")
|
|
37
43
|
# return working client
|
|
38
44
|
yield ft_client
|
|
39
45
|
|
|
@@ -59,6 +65,9 @@ def api(ft_client):
|
|
|
59
65
|
ft_client.setToken("moe", None)
|
|
60
66
|
ft_client.setPass("moe", "bad password")
|
|
61
67
|
ft_client.setToken("moe", "bad token")
|
|
68
|
+
# cookies
|
|
69
|
+
ft_client.setCookie("calvin", "lang", "en_EN")
|
|
70
|
+
ft_client.setCookie("hobbes", "lang", "fr_FR")
|
|
62
71
|
# get valid tokens using password authn
|
|
63
72
|
res = ft_client.get("/login", login="calvin", auth="basic", status=200)
|
|
64
73
|
assert res.json["user"] == "calvin"
|
|
@@ -69,13 +78,13 @@ def api(ft_client):
|
|
|
69
78
|
res = ft_client.post("/login", login="susie", auth="param", status=201, json={})
|
|
70
79
|
assert res.json["user"] == "susie"
|
|
71
80
|
ft_client._auth.setToken("susie", res.json["token"])
|
|
72
|
-
# check that token auth is ok
|
|
81
|
+
# check that token auth and cookie is ok
|
|
73
82
|
res = ft_client.get("/who-am-i", login="calvin", status=200, auth="bearer")
|
|
74
|
-
assert res.json["user"] == "calvin"
|
|
83
|
+
assert res.json["user"] == "calvin" and res.json["lang"] == "en_EN"
|
|
75
84
|
res = ft_client.get("/who-am-i", login="hobbes", status=200, auth="bearer")
|
|
76
|
-
assert res.json["user"] == "hobbes"
|
|
85
|
+
assert res.json["user"] == "hobbes" and res.json["lang"] == "fr_FR"
|
|
77
86
|
res = ft_client.get("/who-am-i", login="susie", status=200, auth="bearer")
|
|
78
|
-
assert res.json["user"] == "susie"
|
|
87
|
+
assert res.json["user"] == "susie" and res.json["lang"] is None
|
|
79
88
|
# with defaults
|
|
80
89
|
res = ft_client.get("/who-am-i", auth="basic", status=200)
|
|
81
90
|
assert res.json["user"] == "calvin"
|
|
@@ -96,17 +105,28 @@ def test_admin(api):
|
|
|
96
105
|
api.check("GET", "/admin", 403, login="hobbes", auth=auth)
|
|
97
106
|
api.check("GET", "/admin", 401, login="moe", auth=auth)
|
|
98
107
|
api.check("GET", "/admin", 401, login=None, auth=auth)
|
|
108
|
+
try:
|
|
109
|
+
api.check("GET", "/admin", status=599, login="calvin", auth=auth)
|
|
110
|
+
pytest.fail("assert on status must fail") # pragma: no cover
|
|
111
|
+
except AssertionError as e:
|
|
112
|
+
assert "200" in str(e)
|
|
113
|
+
try:
|
|
114
|
+
api.check("GET", "/admin", status=200, login="calvin", auth=auth, content="NOT THERE")
|
|
115
|
+
pytest.fail("assert on content must fail") # pragma: no cover
|
|
116
|
+
except AssertionError as e:
|
|
117
|
+
assert "NOT THERE" in str(e)
|
|
99
118
|
|
|
100
119
|
def test_errors(api):
|
|
101
120
|
for scheme in ("header", "cookie", "fake", "tparam", "unexpected"):
|
|
102
121
|
try:
|
|
103
122
|
api.get("/login", login="calvin", auth=scheme)
|
|
104
|
-
|
|
123
|
+
pytest.fail("must raise an exception") # pragma: no cover
|
|
105
124
|
except ft.AuthError as e:
|
|
106
125
|
assert True, "expected error"
|
|
107
126
|
|
|
108
127
|
def test_methods(api):
|
|
109
|
-
api.get("/who-am-i", login="
|
|
128
|
+
res = api.get("/who-am-i", login="susie", status=200, cookies={"lang": "python"})
|
|
129
|
+
assert res.json["lang"] == "python"
|
|
110
130
|
api.post("/who-am-i", login="hobbes", status=405)
|
|
111
131
|
api.put("/who-am-i", login="hobbes", status=405)
|
|
112
132
|
api.patch("/who-am-i", login="hobbes", status=405)
|
|
@@ -119,41 +139,47 @@ def test_authenticator_token():
|
|
|
119
139
|
auth.setToken("susie", "ss-token")
|
|
120
140
|
auth.setToken("hobbes", "hbs-token")
|
|
121
141
|
auth.setToken("moe", "m-token")
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
auth.setCookie("calvin", "what", "clv-cookie")
|
|
143
|
+
kwargs, cookies = {}, {}
|
|
144
|
+
auth.setAuth("calvin", kwargs, cookies, auth="bearer")
|
|
124
145
|
assert kwargs["headers"]["Authorization"] == "Bearer clv-token"
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
assert cookies["what"] == "clv-cookie"
|
|
147
|
+
kwargs, cookies = {}, {}
|
|
148
|
+
auth.setAuth("susie", kwargs, cookies, auth="header")
|
|
127
149
|
assert kwargs["headers"]["Auth"] == "ss-token"
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
assert not cookies
|
|
151
|
+
kwargs, cookies = {"data": {}}, {}
|
|
152
|
+
auth.setAuth("hobbes", kwargs, cookies, auth="tparam")
|
|
130
153
|
assert kwargs["data"]["AUTH"] == "hbs-token"
|
|
131
|
-
|
|
132
|
-
|
|
154
|
+
assert not cookies
|
|
155
|
+
kwargs, cookies = {"json": {}}, {}
|
|
156
|
+
auth.setAuth("hobbes", kwargs, cookies, auth="tparam")
|
|
133
157
|
assert kwargs["json"]["AUTH"] == "hbs-token"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
assert not cookies
|
|
159
|
+
kwargs, cookies = {}, {}
|
|
160
|
+
auth.setAuth("moe", kwargs, cookies, auth="cookie")
|
|
161
|
+
assert not kwargs
|
|
162
|
+
assert cookies["auth"] == "m-token"
|
|
137
163
|
# dad does not have a token
|
|
138
164
|
try:
|
|
139
|
-
kwargs = {}
|
|
140
|
-
auth.setAuth("dad", kwargs)
|
|
141
|
-
|
|
165
|
+
kwargs, cookies = {}, {}
|
|
166
|
+
auth.setAuth("dad", kwargs, cookies)
|
|
167
|
+
pytest.fail("must raise an error") # pragma: no cover
|
|
142
168
|
except ft.FlaskTesterError:
|
|
143
169
|
assert True, "error raised"
|
|
144
170
|
# rosalyn as a password, but no password carrier is allowed
|
|
145
171
|
try:
|
|
146
172
|
auth.setPass("rosalyn", "rsln-pass")
|
|
147
|
-
|
|
173
|
+
pytest.fail("must raise an error") # pragma: no cover
|
|
148
174
|
except ft.AuthError:
|
|
149
175
|
assert True, "error raised"
|
|
150
176
|
# force to trigger later errors
|
|
151
177
|
auth._has_pass = True
|
|
152
178
|
auth.setPass("rosalyn", "rsln-pass")
|
|
153
179
|
try:
|
|
154
|
-
kwargs={}
|
|
155
|
-
auth.setAuth("rosalyn", kwargs)
|
|
156
|
-
|
|
180
|
+
kwargs, cookies = {}, {}
|
|
181
|
+
auth.setAuth("rosalyn", kwargs, cookies)
|
|
182
|
+
pytest.fail("must raise an error") # pragma: no cover
|
|
157
183
|
except ft.FlaskTesterError:
|
|
158
184
|
assert True, "error raised"
|
|
159
185
|
|
|
@@ -164,34 +190,40 @@ def test_authenticator_password():
|
|
|
164
190
|
auth.setPass("hobbes", "hbs-pass")
|
|
165
191
|
auth.setPass("moe", "m-pass")
|
|
166
192
|
auth.setPass("rosalyn", "rsln-pass")
|
|
167
|
-
|
|
168
|
-
|
|
193
|
+
auth.setCookie("moe", "hello", "world!")
|
|
194
|
+
kwargs, cookies = {}, {}
|
|
195
|
+
auth.setAuth("calvin", kwargs, cookies, auth="basic")
|
|
169
196
|
assert kwargs["auth"] == ("calvin", "clv-pass")
|
|
170
|
-
|
|
171
|
-
|
|
197
|
+
assert not cookies
|
|
198
|
+
kwargs, cookies = {"data": {}}, {}
|
|
199
|
+
auth.setAuth("hobbes", kwargs, cookies, auth="param")
|
|
172
200
|
assert kwargs["data"]["USER"] == "hobbes" and kwargs["data"]["PASS"] == "hbs-pass"
|
|
173
|
-
|
|
174
|
-
|
|
201
|
+
assert not cookies
|
|
202
|
+
kwargs, cookies = {"json": {}}, {}
|
|
203
|
+
auth.setAuth("moe", kwargs, cookies, auth="param")
|
|
175
204
|
assert kwargs["json"]["USER"] == "moe" and kwargs["json"]["PASS"] == "m-pass"
|
|
176
|
-
|
|
177
|
-
|
|
205
|
+
assert cookies["hello"] == "world!"
|
|
206
|
+
kwargs, cookies = {"data": {}}, {}
|
|
207
|
+
auth.setAuth("rosalyn", kwargs, cookies, auth="fake")
|
|
178
208
|
assert kwargs["data"]["LOGIN"] == "rosalyn"
|
|
179
|
-
|
|
180
|
-
|
|
209
|
+
assert not cookies
|
|
210
|
+
kwargs, cookies = {"json": {}}, {}
|
|
211
|
+
auth.setAuth("hobbes", kwargs, cookies, auth="fake")
|
|
181
212
|
assert kwargs["json"]["LOGIN"] == "hobbes"
|
|
213
|
+
assert not cookies
|
|
182
214
|
# susie as a token, but no token carrier is allowed
|
|
183
215
|
try:
|
|
184
216
|
auth.setToken("susie", "ss-token")
|
|
185
|
-
|
|
217
|
+
pytest.fail("must raise an error") # pragma: no cover
|
|
186
218
|
except ft.FlaskTesterError:
|
|
187
219
|
assert True, "error raised"
|
|
188
220
|
# force to trigger later error
|
|
189
221
|
auth._has_token = True
|
|
190
222
|
auth.setToken("susie", "ss-token")
|
|
191
223
|
try:
|
|
192
|
-
kwargs={}
|
|
193
|
-
auth.setAuth("susie", kwargs)
|
|
194
|
-
|
|
224
|
+
kwargs, cookies = {}, {}
|
|
225
|
+
auth.setAuth("susie", kwargs, cookies)
|
|
226
|
+
pytest.fail("must raise an error") # pragma: no cover
|
|
195
227
|
except ft.FlaskTesterError:
|
|
196
228
|
assert True, "error raised"
|
|
197
229
|
|
|
@@ -223,10 +255,10 @@ def test_request_flask_response():
|
|
|
223
255
|
def test_client():
|
|
224
256
|
client = ft.Client(ft.Authenticator())
|
|
225
257
|
try:
|
|
226
|
-
client._request("GET", "/")
|
|
227
|
-
|
|
258
|
+
client._request("GET", "/", {})
|
|
259
|
+
pytest.fail("must raise an error") # pragma: no cover
|
|
228
260
|
except NotImplementedError:
|
|
229
|
-
assert True, "error raised"
|
|
261
|
+
assert True, "expected error raised"
|
|
230
262
|
|
|
231
263
|
def test_request_client():
|
|
232
264
|
httpd = htsv.HTTPServer(("", 8888), htsv.SimpleHTTPRequestHandler)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|