Skip to content

Commit 128c37a

Browse files
author
Fabien Coelho
committed
add setHook feature
1 parent 875f626 commit 128c37a

File tree

5 files changed

+60
-20
lines changed

5 files changed

+60
-20
lines changed

FlaskTester.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
import io
88
import re
9-
from typing import Any
9+
from typing import Any, Callable, Self
1010
import importlib
1111
import logging
1212
import pytest # for explicit fail calls, see _pytestFail
@@ -89,6 +89,9 @@ class Authenticator:
8989
_AUTH_SCHEMES.update(_TOKEN_SCHEMES)
9090
_AUTH_SCHEMES.update(_PASS_SCHEMES)
9191

92+
# authenticator login/pass hook
93+
_AuthHook = Callable[[str, str|None], None]
94+
9295
def __init__(self,
9396
allow: list[str] = ["bearer", "basic", "param", "none"],
9497
# parameter names for "basic" and "param"
@@ -124,6 +127,9 @@ def __init__(self,
124127
assert ptype in ("json", "data")
125128
self._ptype = ptype
126129

130+
# _AuthHook|None, but python cannot stand it:-(
131+
self._auth_hook: Any = None
132+
127133
# password and token credentials, cookies
128134
self._passes: dict[str, str] = {}
129135
self._tokens: dict[str, str] = {}
@@ -138,6 +144,9 @@ def _set(self, key: str, val: str|None, store: dict[str, str]):
138144
assert isinstance(val, str)
139145
store[key] = val
140146

147+
def setHook(self, hook: _AuthHook):
148+
self._auth_hook = hook
149+
141150
def setPass(self, login: str, pw: str|None):
142151
"""Associate a password to a user.
143152
@@ -146,6 +155,7 @@ def setPass(self, login: str, pw: str|None):
146155
if not self._has_pass:
147156
raise AuthError("cannot set password, no password scheme allowed")
148157
self._set(login, pw, self._passes)
158+
_ = self._auth_hook and self._auth_hook(login, pw)
149159

150160
def setPasses(self, pws: list[str]):
151161
"""Associate a list of *login:password*."""
@@ -298,11 +308,17 @@ class Client:
298308
:param default_login: When ``login`` is not set.
299309
"""
300310

311+
# client login/pass hook (with mypy workaround)
312+
AuthHook = Callable[[Self, str, str|None], None] # type: ignore
313+
301314
def __init__(self, auth: Authenticator, default_login: str|None = None):
302315
self._auth = auth
303316
self._cookies: dict[str, dict[str, str]] = {} # login -> name -> value
304317
self._default_login = default_login
305318

319+
def setHook(self, hook: AuthHook):
320+
self._auth.setHook(lambda u, p: hook(self, u, p))
321+
306322
def setToken(self, login: str, token: str|None):
307323
"""Associate a token to a login, *None* to remove."""
308324
self._auth.setToken(login, token)

README.md

+9-7
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,20 @@ import pytest
3131
from FlaskTester import ft_authenticator, ft_client
3232
import secret
3333

34+
def authHook(api, user: str, pwd: str|None):
35+
if pwd is not None: # get a token when a login/password is provided
36+
res = api.get("/login", login=user, auth="basic", status=200)
37+
api.setToken(user, res.json["token"])
38+
else: # remove token
39+
api.setToken(user, None)
40+
3441
@pytest.fixture
3542
def app(ft_client):
43+
# register authentication hook
44+
ft_client.setHook(authHook)
3645
# add test passwords for Calvin and Hobbes (must be consistent with app!)
3746
ft_client.setPass("calvin", secret.PASSES["calvin"])
3847
ft_client.setPass("hobbes", secret.PASSES["hobbes"])
39-
# get user tokens, assume json result {"token": "<token-value>"}
40-
res = ft_client.get("/login", login="calvin", auth="basic", status=200)
41-
assert res.is_json
42-
ft_client.setToken("calvin", res.json["token"])
43-
res = ft_client.post("/login", login="hobbes", auth="param", status=201)
44-
assert res.is_json
45-
ft_client.setToken("hobbes", res.json["token"])
4648
# also set a cookie
4749
ft_client.setCookie("hobbes", "lang", "fr")
4850
ft_client.setCookie("calvin", "lang", "en")

docs/documentation.md

+13
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ The package provides two fixtures:
9898
app.get("/stats", 200, login="hobbes")
9999
```
100100

101+
- `setHook` allows to add a hook executed on `setPass`.
102+
The typical use case is to load a token when new credentials are provided.
103+
As with other methods, _None_ is used for removal.
104+
105+
```python
106+
def authHook(client: Client, username: str, password: str|None):
107+
if password:
108+
res = client.post("/login", 201, login=username, auth="param")
109+
client.setToken(username, res.json["token"])
110+
else:
111+
client.setToken(username, None)
112+
```
113+
101114
Moreover, `setPass`, `setToken` and `setCookie` are forwarded to the internal authenticator.
102115

103116
Authenticator environment variables can be set from the pytest Python test file by

docs/versions.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ please report any [issues](https://github.com/zx80/flask-tester/issues).
77

88
## TODO
99

10-
- add hook on setPass?
1110
- setPass and fake auth?
11+
- fixture scope?
1212

1313
## ? on ?
1414

1515
Slightly improve documentation.
16+
Add `setHook`.
1617

1718
## 4.3 on 2024-08-10
1819

tests/test_app.py

+19-11
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
logging.basicConfig(level=logging.INFO)
1414
# logging.basicConfig(level=logging.DEBUG)
15-
# log = logging.getLogger("test")
15+
log = logging.getLogger("test")
1616

1717
# set authn for ft_authenticator
1818
os.environ.update(
@@ -27,23 +27,31 @@ def test_sanity():
2727
# log.debug(f"TEST_SEED={os.environ.get('TEST_SEED')}")
2828

2929
# example from README.md
30+
def authHook(api, user: str, pwd: str|None):
31+
if pwd is not None: # get token
32+
try:
33+
res = api.get("/login", login=user, auth="basic", status=200)
34+
api.setToken(user, res.json["token"])
35+
except ft.FlaskTesterError as e: # pragma: no cover
36+
log.warning(f"error: {e}")
37+
else: # cleanup
38+
api.setToken(user, None)
39+
3040
@pytest.fixture
3141
def app(ft_client):
32-
# add test passwords for Calvin and Hobbes (must be consistent with app!)
42+
# hook when adding login/passwords
43+
ft_client.setHook(authHook)
44+
# Calvin authentication
3345
ft_client.setPass("calvin", secret.PASSES["calvin"])
34-
ft_client.setPass("hobbes", secret.PASSES["hobbes"])
35-
# get user tokens, assume json result {"token": "<token-value>"}
36-
res = ft_client.get("/login", login="calvin", auth="basic", status=200)
37-
assert res.is_json
38-
ft_client.setToken("calvin", res.json["token"])
39-
res = ft_client.post("/login", login="hobbes", auth="param", status=201)
40-
assert res.is_json
41-
ft_client.setToken("hobbes", res.json["token"])
42-
# also set a cookie
4346
ft_client.setCookie("hobbes", "lang", "fr")
47+
# Hobbes authentication
48+
ft_client.setPass("hobbes", secret.PASSES["hobbes"])
4449
ft_client.setCookie("calvin", "lang", "en")
4550
# return working client
4651
yield ft_client
52+
# cleanup
53+
ft_client.setPass("calvin", None)
54+
ft_client.setPass("hobbes", None)
4755

4856
def test_app_admin(app): # GET /admin
4957
app.get("/admin", login=None, status=401)

0 commit comments

Comments
 (0)