Skip to content

Commit

Permalink
add setHook feature
Browse files Browse the repository at this point in the history
  • Loading branch information
Fabien Coelho committed Sep 15, 2024
1 parent 875f626 commit 128c37a
Show file tree
Hide file tree
Showing 5 changed files with 60 additions and 20 deletions.
18 changes: 17 additions & 1 deletion FlaskTester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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] = {}
Expand All @@ -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.
Expand All @@ -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*."""
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<token-value>"}
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")
Expand Down
13 changes: 13 additions & 0 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 19 additions & 11 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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": "<token-value>"}
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)
Expand Down

0 comments on commit 128c37a

Please sign in to comment.