Skip to content

Commit 12b8d48

Browse files
committed
feat: implement test_client parameter
1 parent 022746b commit 12b8d48

File tree

8 files changed

+159
-72
lines changed

8 files changed

+159
-72
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
Versions follow [Semantic Versioning](https://semver.org/>) (<major>.<minor>.<patch>).
99

10+
## [0.2.0] - Unreleased
11+
12+
### Added
13+
14+
- Implement `test_client` attribute.
15+
- Implement `logout` method.
16+
1017
## [0.1.1] - 2025-04-04
1118

1219
### Changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,19 @@ def test_authentication(iam_server, testapp, client):
3636
# create a random user on the IAM server
3737
user = iam_server.random_user()
3838

39-
# logs the user in give its consent to your application
40-
iam_server.login(user)
41-
iam_server.consent(user)
39+
# 1. attempt to access a protected page, returns a redirection to the IAM
40+
res = test_client.get("/protected")
4241

43-
# simulate an attempt to access a protected page of your app
44-
response = testapp.get("/protected", status=302)
42+
# 2. authorization code request
43+
res = iam_server.test_client.get(res.location)
4544

46-
# get an authorization code request at the IAM
47-
res = requests.get(res.location, allow_redirects=False)
45+
# 3. load your application authorization endpoint
46+
res = test_client.get(res.location)
4847

49-
# access to the redirection URI
50-
res = testclient.get(res.headers["Location"])
51-
res.mustcontain("Hello World!")
48+
# 4. now you have access to the protected page
49+
res = test_client.get("/protected")
50+
51+
assert "Hello, world!" in res.text
5252
```
5353

5454
Check the [client application](https://pytest-iam.readthedocs.io/en/latest/client-applications.html) or

doc/client-applications.rst

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ client registration. Here is an example of dynamic registration you can implemen
9494

9595
.. code:: python
9696
97-
response = requests.post(
98-
f"{iam_server.url}/oauth/register",
97+
response = iam_server.test_client.post(
98+
"/oauth/register",
9999
json={
100100
"client_name": "My application",
101101
"client_uri": "http://example.org",
@@ -106,53 +106,79 @@ client registration. Here is an example of dynamic registration you can implemen
106106
"scope": "openid profile groups",
107107
},
108108
)
109-
client_id = response.json()["client_id"]
110-
client_secret = response.json()["client_secret"]
109+
client_id = response.json["client_id"]
110+
client_secret = response.json["client_secret"]
111111
112112
Nominal authentication case
113113
---------------------------
114114

115115
Let us suppose that your application have a ``/protected`` that redirects users
116-
to the IAM server if unauthenticated. With your :class:`~canaille.core.models.User`
117-
and :class:`~canaille.oidc.models.Client` fixtures, you can use the
118-
:meth:`~pytest_iam.Server.login` and :meth:`~pytest_iam.Server.consent` methods
119-
to skip the login and the consent page from the IAM.
120-
116+
to the IAM server if unauthenticated.
121117
We suppose you have a test client fixture like werkzeug :class:`~werkzeug.test.Client`
122-
that allows to test your application endpoints without real HTTP requests. Let
123-
us see how to implement an authorization_code authentication test case:
118+
that allows to test your application endpoints without real HTTP requests.
119+
pytest-iam provides its own test client, available with :meth:`~pytest_iam.Server.test_client`.
120+
Let us see how to implement an authorization_code authentication test case:
124121

125-
.. code:: python
122+
.. code-block:: python
123+
:caption: Full login and consent workflow to get an access token
124+
125+
def test_login_and_consent(iam_server, client, user, test_client):
126+
# 1. attempt to access a protected page
127+
res = test_client.get("/protected")
128+
129+
# 2. redirect to the authorization server login page
130+
res = iam_server.test_client.get(res.location)
131+
132+
# 3. fill the 'login' form at the IAM
133+
res = iam_server.test_client.post(res.location, data={"login": "user"})
134+
135+
# 4. fill the 'password' form at the IAM
136+
res = iam_server.test_client.post(
137+
res.location, data={"password": "correct horse battery staple"}
138+
)
139+
140+
# 5. fill the 'consent' form at the IAM
141+
res = iam_server.test_client.post(res.location, data={"answer": "accept"})
142+
143+
# 6. load your application authorization endpoint
144+
res = test_client.get(res.location)
126145
127-
def test_login_and_consent(iam_server, client, user, testclient):
146+
# 7. now you have access to the protected page
147+
res = test_client.get("/protected")
148+
149+
What happened?
150+
151+
1. A simulation of an access to a protected page on your application. As the page is protected,
152+
it returns a redirection to the IAM login page.
153+
2. The IAM test client loads the login page and get redirected to the login form.
154+
3. The login form is filled, and returns a redirection to the password form.
155+
4. The password form is filled, and returns a redirection to the consent form.
156+
5. The consent form is filled, and return a redirection to your application authorization endpoint with a OAuth code grant.
157+
6. You client authorization endpoint is loaded, it reaches the IAM and exchanges the code grant with a token. This is generally where you fill the session to keep users logged in.
158+
7. The protected page is loaded, and now you should be able to access it.
159+
160+
Steps 2, 3 and 4 can be quite redundant, so pytest-iam provides shortcuts with the
161+
:meth:`~pytest_iam.Server.login` and :meth:`~pytest_iam.Server.consent` methods.
162+
They allow you to skip the login, password and consent pages:
163+
164+
.. code-block:: python
165+
:caption: Fast login and consent workflow to get an access token
166+
167+
def test_login_and_consent(iam_server, client, user, test_client):
128168
iam_server.login(user)
129169
iam_server.consent(user)
130170
131171
# 1. attempt to access a protected page
132-
res = testclient.get("/protected", status=302)
172+
res = test_client.get("/protected")
133173
134174
# 2. authorization code request
135-
res = requests.get(res.location, allow_redirects=False)
175+
res = iam_server.test_client.get(res.location)
136176
137177
# 3. load your application authorization endpoint
138-
res = testclient.get(res.headers["Location"], status=302)
139-
140-
# 4. redirect to the protected page
141-
res = res.follow(status=200)
142-
143-
What happened?
178+
res = test_client.get(res.location)
144179
145-
1. A simulation of an access to a protected page on your application.
146-
2. That redirects to the IAM authorization endpoint. Since the users are already
147-
logged and their consent already given, the IAM redirects to your application
148-
authorization configured redirect_uri, with the authorization code passed in
149-
the query string. Note that ``requests`` is used in this example to perform
150-
the request. Indeed, generally testclient such as the werkzeug one cannot
151-
perform real HTTP requests.
152-
3. Access your application authorization endpoint that will exchange the
153-
authorization code against a token and check the user credentials.
154-
4. For instance, your application can redirect the users back to the page
155-
they attempted to access in the first place.
180+
# 4. now you have access to the protected page
181+
res = test_client.get("/protected")
156182
157183
Error cases
158184
-----------

doc/resource-servers.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ request against the identity server token introspection endpoint.
88

99
.. code:: python
1010
11-
def test_valid_token_auth(iam_server, testclient, client, user):
11+
def test_valid_token_auth(iam_server, test_client, client, user):
1212
token = iam_server.random_token(client=client, subject=user)
13-
res = testclient.get(
13+
res = test_client.get(
1414
"/protected-resource", headers={"Authorization": f"Bearer {token.access_token}"}
1515
)
1616
assert res.status_code == 200
1717
1818
19-
def test_invalid_token_auth(iam_server, testclient):
20-
res = testclient.get(
19+
def test_invalid_token_auth(iam_server, test_client):
20+
res = test_client.get(
2121
"/protected-resource", headers={"Authorization": "Bearer invalid"}
2222
)
2323
assert res.status_code == 401

pytest_iam/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from canaille.oidc.installation import generate_keypair
2121
from flask import Flask
2222
from flask import g
23+
from werkzeug.test import Client
2324

2425

2526
class Server:
@@ -31,6 +32,9 @@ class Server:
3132
app: Flask
3233
"""The authorization server flask app."""
3334

35+
test_client: Client
36+
"""A test client to interact with the IAM without performing real network requests."""
37+
3438
models: ModuleType
3539
"""The module containing the available model classes."""
3640

@@ -42,6 +46,7 @@ class Server:
4246

4347
def __init__(self, app: Flask, port: int, backend: Backend, logging: bool = False):
4448
self.app = app
49+
self.test_client = app.test_client()
4550
self.backend = backend
4651
self.port = port
4752
self.logging = logging
@@ -56,9 +61,19 @@ def __init__(self, app: Flask, port: int, backend: Backend, logging: bool = Fals
5661
def logged_user():
5762
if self.logged_user:
5863
g.user = self.logged_user
64+
else:
65+
try:
66+
del g.user
67+
except AttributeError:
68+
pass
5969

6070
if self.login_datetime:
6171
g.last_login_datetime = self.login_datetime
72+
else:
73+
try:
74+
del g.last_login_datetime
75+
except AttributeError:
76+
pass
6277

6378
def make_request_handler(self):
6479
server = self
@@ -131,6 +146,11 @@ def login(self, user):
131146
self.logged_user = user
132147
self.login_datetime = datetime.datetime.now(datetime.timezone.utc)
133148

149+
def logout(self):
150+
"""Close the current user session if existing."""
151+
self.logged_user = None
152+
self.login_datetime = None
153+
134154
def consent(self, user, client=None):
135155
"""Make a user consent to share data with OIDC clients.
136156

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def user(iam_server):
2525
user_name="user",
2626
formatted_name="John Doe",
2727
emails=["[email protected]"],
28-
password="password",
28+
password="correct horse battery staple",
2929
)
3030
iam_server.backend.save(inst)
3131
yield inst

tests/test_client_application.py

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import uuid
22

33
import pytest
4-
import requests
54
from authlib.integrations.flask_client import OAuth
65
from flask import Flask
76
from flask import jsonify
87
from flask import url_for
98

109

1110
def test_server_configuration(iam_server):
12-
res = requests.get(f"{iam_server.url}/.well-known/openid-configuration")
13-
assert res.json()["issuer"] in iam_server.url
11+
res = iam_server.test_client.get("/.well-known/openid-configuration")
12+
assert res.json["issuer"] in iam_server.url
1413

1514

1615
def test_client_dynamic_registration(iam_server):
17-
response = requests.post(
18-
f"{iam_server.url}/oauth/register",
16+
response = iam_server.test_client.post(
17+
"/oauth/register",
1918
json={
2019
"client_name": "Nubla Dashboard",
2120
"client_uri": "http://client.test",
@@ -26,17 +25,17 @@ def test_client_dynamic_registration(iam_server):
2625
"scope": "openid profile groups",
2726
},
2827
)
29-
client_id = response.json()["client_id"]
30-
client_secret = response.json()["client_secret"]
28+
client_id = response.json["client_id"]
29+
client_secret = response.json["client_secret"]
3130

3231
client = iam_server.backend.get(iam_server.models.Client, client_id=client_id)
3332
assert client.client_secret == client_secret
3433
iam_server.backend.delete(client)
3534

3635

3736
def test_logs(iam_server, caplog):
38-
response = requests.post(
39-
f"{iam_server.url}/oauth/register",
37+
response = iam_server.test_client.post(
38+
"/oauth/register",
4039
json={
4140
"client_name": "Nubla Dashboard",
4241
"client_uri": "http://client.test",
@@ -50,7 +49,7 @@ def test_logs(iam_server, caplog):
5049

5150
assert caplog.records[0].msg == "client registration endpoint request: POST: %s"
5251

53-
client_id = response.json()["client_id"]
52+
client_id = response.json["client_id"]
5453
client = iam_server.backend.get(iam_server.models.Client, client_id=client_id)
5554
iam_server.backend.delete(client)
5655

@@ -86,28 +85,64 @@ def authorize():
8685

8786

8887
@pytest.fixture
89-
def testclient(app):
88+
def test_client(app):
9089
app.config["TESTING"] = True
9190
return app.test_client()
9291

9392

94-
def test_login_and_consent(iam_server, client, user, testclient):
93+
def test_login_and_consent(iam_server, client, user, test_client):
94+
# 1. attempt to access a protected page
95+
res = test_client.get("/login")
96+
97+
# 2. redirect to the authorization server login page
98+
res = iam_server.test_client.get(res.location)
99+
100+
# 3. fill the 'login' form
101+
res = iam_server.test_client.post(res.location, data={"login": "user"})
102+
103+
# 4. fill the 'password' form
104+
res = iam_server.test_client.post(
105+
res.location, data={"password": "correct horse battery staple"}
106+
)
107+
108+
# 5. fill the 'consent' form
109+
res = iam_server.test_client.post(res.location, data={"answer": "accept"})
110+
111+
authorization = iam_server.backend.get(iam_server.models.AuthorizationCode)
112+
assert authorization.client == client
113+
114+
# 6. load your application authorization endpoint
115+
res = test_client.get(res.location)
116+
117+
token = iam_server.backend.get(iam_server.models.Token)
118+
assert token.client == client
119+
120+
assert res.json["userinfo"]["sub"] == "user"
121+
assert res.json["access_token"] == token.access_token
122+
123+
iam_server.logout()
124+
125+
126+
def test_prelogin_and_preconsent(iam_server, client, user, test_client):
95127
iam_server.login(user)
96128
iam_server.consent(user)
97129

98130
# attempt to access a protected page
99-
res = testclient.get("/login")
131+
res = test_client.get("/login")
100132

101133
# authorization code request (already logged in an consented)
102-
res = requests.get(res.location, allow_redirects=False)
134+
res = iam_server.test_client.get(res.location)
103135

104136
authorization = iam_server.backend.get(iam_server.models.AuthorizationCode)
105137
assert authorization.client == client
106138

107-
res = testclient.get(res.headers["Location"])
139+
# return to the client with a code
140+
res = test_client.get(res.location)
108141

109142
token = iam_server.backend.get(iam_server.models.Token)
110143
assert token.client == client
111144

112145
assert res.json["userinfo"]["sub"] == "user"
113146
assert res.json["access_token"] == token.access_token
147+
148+
iam_server.logout()

0 commit comments

Comments
 (0)