Skip to content

Commit 63ef5ec

Browse files
committed
[GPCAPIM-395]: Implement APIM App Auth stub and update authentication logic
- Introduced APIMAppAuthStub to simulate authentication requests - Updated _make_session_post to handle stubbed responses - Modified test cases to use the new stub for authentication - Enhanced session management in the stub for better testing - Adjusted contract tests to verify stub behaviour
1 parent 90fc45b commit 63ef5ec

8 files changed

Lines changed: 161 additions & 34 deletions

File tree

gateway-api/src/gateway_api/apim_app_auth/apim.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
11
import functools
22
import logging
3+
import os
34
import uuid
45
from collections.abc import Callable
56
from datetime import datetime, timedelta, timezone
67
from typing import Any, TypedDict
78

89
import jwt
910
import requests
11+
from stubs.apim_app_auth.stub import APIMAppAuthStub
1012

1113
from gateway_api.apim_app_auth.http import RequestMethod, SessionManager
1214

1315
_logger = logging.getLogger(__name__)
1416

1517

18+
# TODO [GPCAPIM-359]: change this to a new STUB_APIM_APP_AUTH env var
19+
_pds_url = os.getenv("APIM_TOKEN_URL", "not stub")
20+
STUB_APIM_APP_AUTH = _pds_url.strip().lower() == "stub"
21+
22+
23+
def _make_session_post(
24+
session: requests.Session, endpoint: str, data: dict[str, str]
25+
) -> requests.Response:
26+
print("DaveW: in _make_session_post STUB_APIM_APP_AUTH", STUB_APIM_APP_AUTH)
27+
if not STUB_APIM_APP_AUTH:
28+
print("DaveW: in if ", session.post)
29+
return session.post(endpoint, data=data)
30+
else:
31+
stub = APIMAppAuthStub()
32+
print("Dave W: in _make_session_post, stub:", stub.session_post)
33+
print(
34+
"Dave W: in _make_session_post, session_post:",
35+
stub.session_post(session, endpoint, data),
36+
)
37+
response = stub.session_post(session, endpoint, data)
38+
return response
39+
40+
1641
class ApimAuthenticationException(Exception):
1742
pass
1843

@@ -107,7 +132,8 @@ def with_session(session: requests.Session) -> ApimAuthenticator.__AccessToken:
107132

108133
_logger.debug("Sending token request with created session.")
109134

110-
response = session.post(
135+
response = _make_session_post(
136+
session,
111137
self._token_endpoint,
112138
data={
113139
"grant_type": "client_credentials",

gateway-api/src/gateway_api/apim_app_auth/test_apim.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,26 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
3737

3838
return wrapper
3939

40-
@patch("gateway_api.apim_app_auth.http.SessionManager")
40+
@patch("gateway_api.apim_app_auth.apim.APIMAppAuthStub")
4141
@patch("gateway_api.apim_app_auth.apim.jwt.encode")
42-
def test_auth(self, mock_jwt: MagicMock, mock_session_manager: MagicMock) -> None:
43-
mock_session_manager.with_session = self.mock_with_session
44-
42+
def test_auth(
43+
self, mock_jwt: MagicMock, mock_apmim_app_auth_stub: MagicMock
44+
) -> None:
4545
expected_client_assertion = "client_assertion"
4646
mock_jwt.return_value = expected_client_assertion
4747

4848
expected_access_token = "access_token" # noqa S105 - Dummy value
4949
expected_expires_in = timedelta(seconds=5)
5050

51-
self.mock_session.post.return_value.json.return_value = {
51+
(
52+
mock_apmim_app_auth_stub.return_value.session_post.return_value.json.return_value
53+
) = {
5254
"access_token": expected_access_token,
5355
"expires_in": expected_expires_in.total_seconds(),
5456
}
55-
self.mock_session.post.return_value.status_code = 200
57+
mock_apmim_app_auth_stub.return_value.session_post.return_value.status_code = (
58+
200
59+
)
5660

5761
expected_api_key = "api_key"
5862
expected_token_endpoint = "token_endpoint" # noqa S106 - Dummy value
@@ -63,7 +67,7 @@ def test_auth(self, mock_jwt: MagicMock, mock_session_manager: MagicMock) -> Non
6367
api_key=expected_api_key,
6468
token_validity_threshold=timedelta(minutes=5),
6569
token_endpoint=expected_token_endpoint,
66-
session_manager=mock_session_manager,
70+
session_manager=Mock(),
6771
)
6872

6973
@apim_authenticator.auth
@@ -128,24 +132,26 @@ def method(_: requests.Session) -> None:
128132

129133
method()
130134

131-
@patch("gateway_api.apim_app_auth.http.SessionManager")
135+
@patch("gateway_api.apim_app_auth.apim.APIMAppAuthStub")
132136
@patch("gateway_api.apim_app_auth.apim.jwt.encode")
133137
def test_auth_existing_invalid_token(
134-
self, mock_jwt: MagicMock, mock_session_manager: MagicMock
138+
self, mock_jwt: MagicMock, mock_apmim_app_auth_stub: MagicMock
135139
) -> None:
136-
mock_session_manager.with_session = self.mock_with_session
137-
138140
expected_client_assertion = "client_assertion"
139141
mock_jwt.return_value = expected_client_assertion
140142

141143
expected_access_token = "access_token" # noqa S105 - Dummy value
142144
expected_expires_in = timedelta(seconds=5)
143145

144-
self.mock_session.post.return_value.json.return_value = {
146+
(
147+
mock_apmim_app_auth_stub.return_value.session_post.return_value.json.return_value
148+
) = {
145149
"access_token": expected_access_token,
146150
"expires_in": expected_expires_in.total_seconds(),
147151
}
148-
self.mock_session.post.return_value.status_code = 200
152+
mock_apmim_app_auth_stub.return_value.session_post.return_value.status_code = (
153+
200
154+
)
149155

150156
expected_api_key = "api_key"
151157
expected_token_endpoint = "token_endpoint" # noqa S106 - Dummy value
@@ -156,7 +162,7 @@ def test_auth_existing_invalid_token(
156162
api_key=expected_api_key,
157163
token_validity_threshold=timedelta(minutes=5),
158164
token_endpoint=expected_token_endpoint,
159-
session_manager=mock_session_manager,
165+
session_manager=Mock(),
160166
)
161167

162168
apim_authenticator._access_token = { # noqa SLF001 - Private access to support testing
@@ -223,17 +229,21 @@ def method(_: requests.Session) -> None:
223229
with pytest.raises(ApimAuthenticationException):
224230
method()
225231

232+
@patch("gateway_api.apim_app_auth.apim.APIMAppAuthStub")
226233
@patch("gateway_api.apim_app_auth.http.SessionManager")
227234
@patch("gateway_api.apim_app_auth.apim.jwt.encode")
228235
def test_auth_session_post_raises_exception(
229-
self, mock_jwt: MagicMock, mock_session_manager: MagicMock
236+
self,
237+
mock_jwt: MagicMock,
238+
mock_session_manager: MagicMock,
239+
mock_apim_app_auth_stub: MagicMock,
230240
) -> None:
231241
mock_session_manager.with_session = self.mock_with_session
232242

233243
mock_jwt.return_value = "client_assertion"
234244

235-
self.mock_session.post.side_effect = requests.RequestException(
236-
"Connection failed"
245+
mock_apim_app_auth_stub.return_value.session_post.side_effect = (
246+
requests.RequestException("Connection failed")
237247
)
238248

239249
apim_authenticator = ApimAuthenticator(

gateway-api/src/gateway_api/pds/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def search_patient_by_nhs_number(
169169
return patient
170170

171171

172+
# TODO [GPCAPIM-359]: consider moving this to a nested method
172173
@environment.apim_authenticator().auth
173174
def _make_get_request(
174175
session: requests.Session,

gateway-api/stubs/stubs/apim_app_auth/stub.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,21 @@
44
The stub does **not** implement the full APIM APP Auth API surface.
55
"""
66

7+
import logging
78
from typing import Any
89

9-
from requests import Response
10+
from requests import Response, Session
1011

11-
from stubs.base_stub import PostStub, StubBase
12+
from stubs.base_stub import SessionPostStub, StubBase
1213

1314

14-
class APIMAppAuthStub(StubBase, PostStub):
15+
class APIMAppAuthStub(StubBase, SessionPostStub):
1516
def __init__(self) -> None:
1617
self._post_url: str = ""
1718
self._post_headers: dict[str, str] = {}
18-
self._post_data: str = ""
19+
self._post_data: str | dict[str, str] = {}
1920
self._post_timeout: int | None = None
21+
self._post_session: Session = Session()
2022

2123
@property
2224
def post_url(self) -> str:
@@ -27,31 +29,56 @@ def post_headers(self) -> dict[str, str]:
2729
return self._post_headers
2830

2931
@property
30-
def post_data(self) -> str:
32+
def post_data(self) -> str | dict[str, str]:
3133
return self._post_data
3234

3335
@property
3436
def post_timeout(self) -> int | None:
3537
return self._post_timeout
3638

37-
# TODO: validation?
39+
@property
40+
def post_session(self) -> Session:
41+
return self._post_session
3842

3943
def post(
4044
self,
4145
url: str,
4246
data: str,
43-
**kwargs: Any, # noqa: ARG002 - kwargs are required to match subclass signature
47+
**kwargs: Any,
4448
) -> Response:
45-
self._post_url = url
46-
self._post_data = data
49+
return self.session_post(
50+
session=kwargs.pop("session", Session()),
51+
url=url,
52+
data=data,
53+
**kwargs,
54+
)
55+
56+
def session_post(
57+
self,
58+
session: Session,
59+
url: str, # noqa: ARG002 - required to match subclass signature
60+
data: str | dict[str, str], # noqa: ARG002 - required to match subclass signature
61+
**kwargs: Any, # noqa: ARG002 - required to match subclass signature
62+
) -> Response:
63+
logger = logging.getLogger(__name__)
64+
logger.info("DaveW in stub session_post data: %s", data)
65+
if session is None:
66+
raise ValueError("Session must be provided for APIMAppAuthStub")
67+
68+
# contract test flag on env var e.g. api_token
69+
70+
# session_post_response = session.post(url, data=data, **kwargs)
4771

72+
# if session_post_response.text == "Unauthorized":
73+
# response = self._create_response(
74+
# status_code=401, json_data={"error": "Unauthorized"}
75+
# )
76+
# else:
4877
response = self._create_response(
4978
status_code=200,
5079
json_data={
5180
"access_token": "access_token",
52-
"expires_in": "599",
53-
"token_type": "Bearer",
54-
"issued_at": "1777366854223",
81+
"expires_in": "5",
5582
},
5683
)
5784

gateway-api/stubs/stubs/base_stub.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from http.client import responses as http_responses
1212
from typing import Any, Protocol
1313

14-
from requests import Response
14+
from requests import Response, Session
1515
from requests.structures import CaseInsensitiveDict
1616

1717

@@ -113,7 +113,7 @@ def post_headers(self) -> dict[str, str]:
113113

114114
@property
115115
@abstractmethod
116-
def post_data(self) -> str:
116+
def post_data(self) -> str | dict[str, str]:
117117
"""
118118
Last post request body stub.post was called with. Empty if not called yet.
119119
"""
@@ -124,3 +124,24 @@ def post_timeout(self) -> int | None:
124124
"""
125125
Last timeout value stub.post was called with. None if not called yet.
126126
"""
127+
128+
129+
class SessionPostStub(PostStub, Protocol):
130+
@abstractmethod
131+
def session_post(
132+
self,
133+
session: Session,
134+
url: str,
135+
data: dict[str, str],
136+
**kwargs: Any,
137+
) -> Response:
138+
"""
139+
Handle HTTP POST requests for the stub, using a provided Session.
140+
"""
141+
142+
@property
143+
@abstractmethod
144+
def post_session(self) -> Session:
145+
"""
146+
Last Session stub.session_post was called with. None if not called yet.
147+
"""
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
import requests
5+
from stubs.apim_app_auth.stub import APIMAppAuthStub
6+
7+
8+
@pytest.fixture
9+
def apim_app_auth_stub() -> APIMAppAuthStub:
10+
return APIMAppAuthStub()
11+
12+
13+
@pytest.fixture
14+
def mock_session() -> Mock:
15+
return Mock()
16+
17+
18+
class TestAPIMAppAuthSuccess:
19+
def setup_method(self) -> None:
20+
self.mock_session = Mock()
21+
22+
def test_post_success(
23+
self, apim_app_auth_stub: APIMAppAuthStub, mock_session: Mock
24+
) -> None:
25+
mock_session.post.return_value.json.return_value = {
26+
"access_token": "access_token",
27+
"expires_in": "5",
28+
}
29+
mock_session.post.return_value.status_code = 200
30+
31+
response = apim_app_auth_stub.session_post(
32+
requests.Session(),
33+
url="https://example.com/token",
34+
data="grant_type=client_credentials&client_id=abc&client_secret=def",
35+
)
36+
37+
assert response.status_code == 200
38+
assert response.json() == {
39+
"access_token": "access_token",
40+
"expires_in": "5",
41+
}

gateway-api/tests/contract/stub/test_sds_stub_contract.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,13 @@ def test_endpoint_bundle_matches_expected_response(
238238
assert body["type"] == "searchset"
239239
assert body["total"] == len(body["entry"])
240240

241-
assert len(body["entry"]) == 4
241+
assert len(body["entry"]) == 5
242242
endpoint_ids = [
243243
"E0E0E921-92CA-4A88-A550-2DBB36F703AF",
244244
"E1E1E921-92CA-4A88-A550-2DBB36F703AF",
245245
"E2E2E921-92CA-4A88-A550-2DBB36F703AF",
246246
"E3E3E921-92CA-4A88-A550-2DBB36F703AF",
247+
"E3E3E921-92CA-4A88-A550-2DBB36F703AF",
247248
]
248249
for i in range(len(endpoint_ids)):
249250
entry = body["entry"][i]

gateway-api/tests/contract/test_provider_contract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from tests.conftest import Client
1313

1414

15-
def test_provider_honors_consumer_contract(headers: Any, client: Client) -> None:
15+
def test_provider_honours_consumer_contract(headers: Any, client: Client) -> None:
1616

1717
host = urlparse(client.base_url).hostname
1818

0 commit comments

Comments
 (0)