Skip to content

Commit f03cdcd

Browse files
VED-912: Add first performance tests. (#1212)
* updated authenticator to cache in-memory and use dependency injection * added locustfile with create/delete and search tests * comments in PR --------- Co-authored-by: Akshay Shetty <akshay.shetty1@nhs.net>
1 parent c1c4158 commit f03cdcd

13 files changed

Lines changed: 288 additions & 280 deletions

File tree

README.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ See https://nhsd-confluence.digital.nhs.uk/display/APM/Glossary.
2626
| `id_sync` | **Imms Cross-cutting** – Handles [MNS](https://digital.nhs.uk/developer/api-catalogue/multicast-notification-service) NHS Number Change events and applies updates to affected records. |
2727
| `mesh_processor` | **Imms Batch** – Triggered when new files are received via MESH. Moves them into the Imms Batch processing system. |
2828
| `mns_subscription` | **Imms Cross-cutting** – Simple helper Lambda which sets up our required MNS subscription. Used in pipelines in DEV. |
29+
| `perf_tests` | **Imms API** – Locust performance tests for the Immunisation API. |
2930
| `recordforwarder` | **Imms Batch** – Consumes from the stream and applies the processed batch file row operations (CUD) to IEDS. |
3031
| `recordprocessor` | **Imms Batch** – ECS Task - **not** a Lambda function - responsible for processing batch file rows and forwarding to the stream. |
3132
| `redis_sync` | **Imms Cross-cutting** – Handles config file updates. E.g. disease mapping or permission files. |
@@ -142,22 +143,12 @@ Steps:
142143
pip install poetry
143144
```
144145
145-
### Install Pre-Commit Hooks
146-
147-
[Husky](https://typicode.github.io/husky/) is used to perform automatic checks upon making a commit.
148-
It is configured within `.husky/pre-commit` to run the checks defined in the root level `package.json` under `lint-staged`.
149-
To set this up:
150-
151-
1. Ensure you have installed nodejs at the same version or later as per .tool-versions and
146+
8. Install pre-commit hooks. Ensure you have installed nodejs at the same version or later as per .tool-versions and
152147
then, from the repo root, run:
153-
154148
```
155149
npm install
156150
```
157151
158-
2. Run `cd quality_checks` then `poetry install --no-root`. This will make sure your version of ruff is the same as used in the GitHub pipeline.
159-
You can check your version is correct by running `poetry run ruff --version` from within the `quality_checks` directory and comparing to the version in the poetry.lock file.
160-
161152
### Setting up a virtual environment with poetry
162153
163154
The steps below must be performed in each Lambda function folder and e2e_automation folder to ensure the environment is correctly configured.
@@ -216,6 +207,18 @@ Steps:
216207
217208
It is not necessary to activate the virtual environment (using `source .venv/bin/activate`) before running a unit test suite from the command line; `direnv` will pick up the correct configurations for us. Run `pip list` to verify that the expected packages are installed. You should for example see that `recordprocessor` is specifically running `moto` v4, regardless of which if any `.venv` is active.
218209
210+
### Setting up the root level environment
211+
212+
The root-level virtual environment is primarily used for linting, as we create separate virtual environments for each folder that contains Lambda functions.
213+
Steps:
214+
215+
1. Follow instructions above to [install dependencies](#install-dependencies) & [set up a virtual environment](#setting-up-a-virtual-environment-with-poetry).
216+
**Note: While this project uses Python 3.11 (e.g. for Lambdas), the NHSDigital/api-management-utils repository — which orchestrates setup and linting — defaults to Python 3.8.
217+
The linting command is executed from within that repo but calls the Makefile in this project, so be aware of potential Python version mismatches when running or debugging locally or in the pipeline.**
218+
2. Run `make lint`. This will:
219+
- Check the linting of the API specification yaml.
220+
- Run Flake8 on all Python files in the repository, excluding files inside .venv and .terraform directories.
221+
219222
## IDE setup
220223
221224
The current team uses VS Code mainly. So this setup is targeted towards VS code. If you use another IDE please add the documentation to set up workspaces here.

lambdas/id_sync/src/pds_details.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44

55
import tempfile
66

7-
from common.api_clients.authentication import AppRestrictedAuth, Service
7+
from common.api_clients.authentication import AppRestrictedAuth
88
from common.api_clients.pds_service import PdsService
9-
from common.cache import Cache
109
from common.clients import get_secrets_manager_client, logger
1110
from exceptions.id_sync_exception import IdSyncException
1211
from os_vars import get_pds_env
@@ -18,12 +17,9 @@
1817
# Get Patient details from external service PDS using NHS number from MNS notification
1918
def pds_get_patient_details(nhs_number: str) -> dict:
2019
try:
21-
cache = Cache(directory=safe_tmp_dir)
2220
authenticator = AppRestrictedAuth(
23-
service=Service.PDS,
2421
secret_manager_client=get_secrets_manager_client(),
2522
environment=pds_env,
26-
cache=cache,
2723
)
2824
pds_service = PdsService(authenticator, pds_env)
2925
patient = pds_service.get_patient_details(nhs_number)

lambdas/id_sync/tests/test_pds_details.py

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@ def setUp(self):
2121
self.mock_pds_env = self.pds_env_patcher.start()
2222
self.mock_pds_env.return_value = "test-env"
2323

24-
self.cache_patcher = patch("pds_details.Cache")
25-
self.mock_cache_class = self.cache_patcher.start()
26-
self.mock_cache_instance = MagicMock()
27-
self.mock_cache_class.return_value = self.mock_cache_instance
28-
2924
self.auth_patcher = patch("pds_details.AppRestrictedAuth")
3025
self.mock_auth_class = self.auth_patcher.start()
3126
self.mock_auth_instance = MagicMock()
@@ -57,9 +52,6 @@ def test_pds_get_patient_details_success(self):
5752
# Assert
5853
self.assertEqual(result["identifier"][0]["value"], "9912003888")
5954

60-
# Verify Cache was initialized correctly
61-
self.mock_cache_class.assert_called_once()
62-
6355
# Verify get_patient_details was called
6456
self.mock_pds_service_instance.get_patient_details.assert_called_once()
6557

@@ -110,27 +102,6 @@ def test_pds_get_patient_details_pds_service_exception(self):
110102

111103
self.mock_pds_service_instance.get_patient_details.assert_called_once_with(self.test_patient_id)
112104

113-
def test_pds_get_patient_details_cache_initialization_error(self):
114-
"""Test when Cache initialization fails"""
115-
# Arrange
116-
self.mock_cache_class.side_effect = OSError("Cannot write to /tmp")
117-
118-
# Act
119-
with self.assertRaises(IdSyncException) as context:
120-
pds_get_patient_details(self.test_patient_id)
121-
122-
# Assert
123-
exception = context.exception
124-
self.assertEqual(
125-
exception.message,
126-
"Error retrieving patient details from PDS",
127-
)
128-
129-
# Verify exception was logged
130-
self.mock_logger.exception.assert_called_once_with("Error retrieving patient details from PDS")
131-
132-
self.mock_cache_class.assert_called_once()
133-
134105
def test_pds_get_patient_details_auth_initialization_error(self):
135106
"""Test when AppRestrictedAuth initialization fails"""
136107
# Arrange

lambdas/mns_subscription/src/mns_setup.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,18 @@
33
import boto3
44
from botocore.config import Config
55

6-
from common.api_clients.authentication import AppRestrictedAuth, Service
6+
from common.api_clients.authentication import AppRestrictedAuth
77
from common.api_clients.mns_service import MnsService
8-
from common.cache import Cache
98

109
logging.basicConfig(level=logging.INFO)
1110

1211

1312
def get_mns_service(mns_env: str = "int"):
1413
boto_config = Config(region_name="eu-west-2")
15-
cache = Cache(directory="/tmp")
16-
logging.info("Creating authenticator...")
17-
# VED-1087 TODO: MNS and PDS need separate secrets
14+
1815
authenticator = AppRestrictedAuth(
19-
service=Service.PDS,
2016
secret_manager_client=boto3.client("secretsmanager", config=boto_config),
2117
environment=mns_env,
22-
cache=cache,
2318
)
2419

25-
logging.info("Authentication Initiated...")
2620
return MnsService(authenticator)

lambdas/shared/src/common/api_clients/authentication.py

Lines changed: 41 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,30 @@
22
import json
33
import time
44
import uuid
5-
from enum import Enum
65

76
import jwt
87
import requests
98

109
from common.clients import logger
1110
from common.models.errors import UnhandledResponseError
1211

13-
from ..cache import Cache
12+
GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
13+
CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
14+
CONTENT_TYPE_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded"
1415

15-
16-
class Service(Enum):
17-
PDS = "pds"
18-
IMMUNIZATION = "imms"
16+
JWT_EXPIRY_SECONDS = 5 * 60
17+
ACCESS_TOKEN_EXPIRY_SECONDS = 10 * 60
18+
# Throw away the cached token earlier than the expiry time
19+
ACCESS_TOKEN_MIN_ACCEPTABLE_LIFETIME_SECONDS = 30
1920

2021

2122
class AppRestrictedAuth:
22-
def __init__(self, service: Service, secret_manager_client, environment, cache: Cache):
23+
def __init__(self, secret_manager_client, environment, secret_name=None):
2324
self.secret_manager_client = secret_manager_client
24-
self.cache = cache
25-
self.cache_key = f"{service.value}_access_token"
26-
27-
self.expiry = 30
28-
self.secret_name = (
29-
f"imms/pds/{environment}/jwt-secrets"
30-
if service == Service.PDS
31-
else f"imms/immunization/{environment}/jwt-secrets"
32-
)
25+
self.cached_access_token = None
26+
self.cached_access_token_expiry_time = None
27+
28+
self.secret_name = f"imms/pds/{environment}/jwt-secrets" if secret_name is None else secret_name
3329

3430
self.token_url = (
3531
f"https://{environment}.api.service.nhs.uk/oauth2/token"
@@ -38,60 +34,56 @@ def __init__(self, service: Service, secret_manager_client, environment, cache:
3834
)
3935

4036
def get_service_secrets(self):
41-
kwargs = {"SecretId": self.secret_name}
42-
response = self.secret_manager_client.get_secret_value(**kwargs)
37+
response = self.secret_manager_client.get_secret_value(SecretId=self.secret_name)
4338
secret_object = json.loads(response["SecretString"])
4439
secret_object["private_key"] = base64.b64decode(secret_object["private_key_b64"]).decode()
4540

4641
return secret_object
4742

4843
def create_jwt(self, now: int):
49-
logger.info("create_jwt")
5044
secret_object = self.get_service_secrets()
51-
claims = {
52-
"iss": secret_object["api_key"],
53-
"sub": secret_object["api_key"],
54-
"aud": self.token_url,
55-
"iat": now,
56-
"exp": now + self.expiry,
57-
"jti": str(uuid.uuid4()),
58-
}
5945

6046
return jwt.encode(
61-
claims,
47+
{
48+
"iss": secret_object["api_key"],
49+
"sub": secret_object["api_key"],
50+
"aud": self.token_url,
51+
"iat": now,
52+
"exp": now + JWT_EXPIRY_SECONDS,
53+
"jti": str(uuid.uuid4()),
54+
},
6255
secret_object["private_key"],
6356
algorithm="RS512",
6457
headers={"kid": secret_object["kid"]},
6558
)
6659

6760
def get_access_token(self):
68-
logger.info("get_access_token")
6961
now = int(time.time())
70-
logger.info(f"Current time: {now}, Expiry time: {now + self.expiry}")
7162
# Check if token is cached and not expired
72-
logger.info(f"Cache key: {self.cache_key}")
73-
logger.info("Checking cache for access token")
74-
cached = self.cache.get(self.cache_key)
75-
76-
if cached and cached["expires_at"] > now:
77-
logger.info("Returning cached access token")
78-
return cached["token"]
79-
80-
logger.info("No valid cached token found, creating new token")
81-
_jwt = self.create_jwt(now)
82-
83-
headers = {"Content-Type": "application/x-www-form-urlencoded"}
84-
data = {
85-
"grant_type": "client_credentials",
86-
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
87-
"client_assertion": _jwt,
88-
}
89-
token_response = requests.post(self.token_url, data=data, headers=headers)
63+
if (
64+
self.cached_access_token
65+
and self.cached_access_token_expiry_time > now + ACCESS_TOKEN_MIN_ACCEPTABLE_LIFETIME_SECONDS
66+
):
67+
return self.cached_access_token
68+
69+
logger.info("Requesting new access token")
70+
signed_jwt = self.create_jwt(now)
71+
72+
token_response = requests.post(
73+
self.token_url,
74+
data={
75+
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
76+
"client_assertion_type": CLIENT_ASSERTION_TYPE_JWT_BEARER,
77+
"client_assertion": signed_jwt,
78+
},
79+
headers={"Content-Type": CONTENT_TYPE_X_WWW_FORM_URLENCODED},
80+
)
9081
if token_response.status_code != 200:
9182
raise UnhandledResponseError(response=token_response.text, message="Failed to get access token")
9283

9384
token = token_response.json().get("access_token")
9485

95-
self.cache.put(self.cache_key, {"token": token, "expires_at": now + self.expiry})
86+
self.cached_access_token = token
87+
self.cached_access_token_expiry_time = now + ACCESS_TOKEN_EXPIRY_SECONDS
9688

9789
return token

lambdas/shared/src/common/cache.py

Lines changed: 0 additions & 33 deletions
This file was deleted.

0 commit comments

Comments
 (0)