diff --git a/FlaskTester.py b/FlaskTester.py index 5629879..8a0c011 100755 --- a/FlaskTester.py +++ b/FlaskTester.py @@ -6,7 +6,7 @@ import os import io import re -from typing import Any +from typing import Any, Callable, Self import importlib import logging import pytest # for explicit fail calls, see _pytestFail @@ -89,6 +89,9 @@ class Authenticator: _AUTH_SCHEMES.update(_TOKEN_SCHEMES) _AUTH_SCHEMES.update(_PASS_SCHEMES) + # authenticator login/pass hook + _AuthHook = Callable[[str, str|None], None] + def __init__(self, allow: list[str] = ["bearer", "basic", "param", "none"], # parameter names for "basic" and "param" @@ -124,6 +127,9 @@ def __init__(self, assert ptype in ("json", "data") self._ptype = ptype + # _AuthHook|None, but python cannot stand it:-( + self._auth_hook: Any = None + # password and token credentials, cookies self._passes: dict[str, str] = {} self._tokens: dict[str, str] = {} @@ -138,6 +144,9 @@ def _set(self, key: str, val: str|None, store: dict[str, str]): assert isinstance(val, str) store[key] = val + def setHook(self, hook: _AuthHook): + self._auth_hook = hook + def setPass(self, login: str, pw: str|None): """Associate a password to a user. @@ -146,6 +155,7 @@ def setPass(self, login: str, pw: str|None): if not self._has_pass: raise AuthError("cannot set password, no password scheme allowed") self._set(login, pw, self._passes) + _ = self._auth_hook and self._auth_hook(login, pw) def setPasses(self, pws: list[str]): """Associate a list of *login:password*.""" @@ -298,11 +308,17 @@ class Client: :param default_login: When ``login`` is not set. """ + # client login/pass hook (with mypy workaround) + AuthHook = Callable[[Self, str, str|None], None] # type: ignore + def __init__(self, auth: Authenticator, default_login: str|None = None): self._auth = auth self._cookies: dict[str, dict[str, str]] = {} # login -> name -> value self._default_login = default_login + def setHook(self, hook: AuthHook): + self._auth.setHook(lambda u, p: hook(self, u, p)) + def setToken(self, login: str, token: str|None): """Associate a token to a login, *None* to remove.""" self._auth.setToken(login, token) diff --git a/README.md b/README.md index 5ce7e43..2b78b8e 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,20 @@ import pytest from FlaskTester import ft_authenticator, ft_client import secret +def authHook(api, user: str, pwd: str|None): + if pwd is not None: # get a token when a login/password is provided + res = api.get("/login", login=user, auth="basic", status=200) + api.setToken(user, res.json["token"]) + else: # remove token + api.setToken(user, None) + @pytest.fixture def app(ft_client): + # register authentication hook + ft_client.setHook(authHook) # add test passwords for Calvin and Hobbes (must be consistent with app!) ft_client.setPass("calvin", secret.PASSES["calvin"]) ft_client.setPass("hobbes", secret.PASSES["hobbes"]) - # get user tokens, assume json result {"token": ""} - res = ft_client.get("/login", login="calvin", auth="basic", status=200) - assert res.is_json - ft_client.setToken("calvin", res.json["token"]) - res = ft_client.post("/login", login="hobbes", auth="param", status=201) - assert res.is_json - ft_client.setToken("hobbes", res.json["token"]) # also set a cookie ft_client.setCookie("hobbes", "lang", "fr") ft_client.setCookie("calvin", "lang", "en") diff --git a/docs/documentation.md b/docs/documentation.md index d4417cc..b7aa7f7 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -98,6 +98,19 @@ The package provides two fixtures: app.get("/stats", 200, login="hobbes") ``` + - `setHook` allows to add a hook executed on `setPass`. + The typical use case is to load a token when new credentials are provided. + As with other methods, _None_ is used for removal. + + ```python + def authHook(client: Client, username: str, password: str|None): + if password: + res = client.post("/login", 201, login=username, auth="param") + client.setToken(username, res.json["token"]) + else: + client.setToken(username, None) + ``` + Moreover, `setPass`, `setToken` and `setCookie` are forwarded to the internal authenticator. Authenticator environment variables can be set from the pytest Python test file by diff --git a/docs/versions.md b/docs/versions.md index 6fb3165..2a2cbd0 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -7,12 +7,13 @@ please report any [issues](https://github.com/zx80/flask-tester/issues). ## TODO -- add hook on setPass? - setPass and fake auth? +- fixture scope? ## ? on ? Slightly improve documentation. +Add `setHook`. ## 4.3 on 2024-08-10 diff --git a/tests/test_app.py b/tests/test_app.py index 57c0215..33b43ef 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -12,7 +12,7 @@ logging.basicConfig(level=logging.INFO) # logging.basicConfig(level=logging.DEBUG) -# log = logging.getLogger("test") +log = logging.getLogger("test") # set authn for ft_authenticator os.environ.update( @@ -27,23 +27,31 @@ def test_sanity(): # log.debug(f"TEST_SEED={os.environ.get('TEST_SEED')}") # example from README.md +def authHook(api, user: str, pwd: str|None): + if pwd is not None: # get token + try: + res = api.get("/login", login=user, auth="basic", status=200) + api.setToken(user, res.json["token"]) + except ft.FlaskTesterError as e: # pragma: no cover + log.warning(f"error: {e}") + else: # cleanup + api.setToken(user, None) + @pytest.fixture def app(ft_client): - # add test passwords for Calvin and Hobbes (must be consistent with app!) + # hook when adding login/passwords + ft_client.setHook(authHook) + # Calvin authentication ft_client.setPass("calvin", secret.PASSES["calvin"]) - ft_client.setPass("hobbes", secret.PASSES["hobbes"]) - # get user tokens, assume json result {"token": ""} - res = ft_client.get("/login", login="calvin", auth="basic", status=200) - assert res.is_json - ft_client.setToken("calvin", res.json["token"]) - res = ft_client.post("/login", login="hobbes", auth="param", status=201) - assert res.is_json - ft_client.setToken("hobbes", res.json["token"]) - # also set a cookie ft_client.setCookie("hobbes", "lang", "fr") + # Hobbes authentication + ft_client.setPass("hobbes", secret.PASSES["hobbes"]) ft_client.setCookie("calvin", "lang", "en") # return working client yield ft_client + # cleanup + ft_client.setPass("calvin", None) + ft_client.setPass("hobbes", None) def test_app_admin(app): # GET /admin app.get("/admin", login=None, status=401)