Skip to content

Commit bfa9629

Browse files
Feature/eli 216 nhs number matching (#189)
* negative test for iteration type * negative test for iteration type * negative test for iteration type * Int tests now passing with decorator func * api gateway IT tests * sonar suppress - covered by lambda tests * NHS_NUMBER_HEADER_NAME in constants --------- Co-authored-by: karthikeyannhs <[email protected]>
1 parent 26ca875 commit bfa9629

File tree

6 files changed

+205
-15
lines changed

6 files changed

+205
-15
lines changed

src/eligibility_signposting_api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from eligibility_signposting_api.config.config import config, init_logging
1212
from eligibility_signposting_api.error_handler import handle_exception
1313
from eligibility_signposting_api.views import eligibility_blueprint
14+
from eligibility_signposting_api.wrapper import validate_matching_nhs_number
1415

1516
init_logging()
1617
logger = logging.getLogger(__name__)
@@ -22,6 +23,7 @@ def main() -> None: # pragma: no cover
2223
app.run(debug=config()["log_level"] == logging.DEBUG)
2324

2425

26+
@validate_matching_nhs_number()
2527
def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover
2628
"""Run the Flask app as an AWS Lambda."""
2729
app = create_app()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
MAGIC_COHORT_LABEL = "elid_all_people"
22
RULE_STOP_DEFAULT = False
3+
NHS_NUMBER_HEADER_NAME = "nhs-login-nhs-number"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import logging
2+
from collections.abc import Callable
3+
from functools import wraps
4+
5+
from mangum.types import LambdaContext, LambdaEvent
6+
7+
from eligibility_signposting_api.config.contants import NHS_NUMBER_HEADER_NAME
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class MismatchedNHSNumberError(ValueError):
13+
pass
14+
15+
16+
def validate_matching_nhs_number() -> Callable:
17+
def decorator(func: Callable) -> Callable: # pragma: no cover
18+
@wraps(func)
19+
def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, int | str]:
20+
headers = event.get("headers", {})
21+
path_params = event.get("pathParameters", {})
22+
23+
header_nhs = headers.get(NHS_NUMBER_HEADER_NAME)
24+
path_nhs = path_params.get("id")
25+
26+
logger.info("nhs numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs})
27+
28+
if header_nhs != path_nhs:
29+
logger.error("NHS number mismatch", extra={"header_nhs_no": header_nhs, "path_nhs_no": path_nhs})
30+
return {"statusCode": 403, "body": "NHS number mismatch"}
31+
return func(event, context)
32+
33+
return wrapper
34+
35+
return decorator

tests/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ services:
99
# LocalStack configuration: https://docs.localstack.cloud/references/configuration/
1010
- DEBUG=${LOCALSTACK_DEBUG:-0}
1111
- DEFAULT_REGION=${AWS_DEFAULT_REGION:-eu-west-1}
12-
- LAMBDA_EXECUTOR=docker
12+
- LAMBDA_EXECUTOR=local
1313
volumes:
1414
- "${LOCALSTACK_VOLUME_DIR:-../volume}:/var/lib/localstack"
1515
- "/var/run/docker.sock:/var/run/docker.sock"

tests/integration/conftest.py

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ def boto3_session() -> Session:
6262
return Session(aws_access_key_id="fake", aws_secret_access_key="fake", region_name=AWS_REGION)
6363

6464

65+
@pytest.fixture(scope="session")
66+
def api_gateway_client(boto3_session: Session, localstack: URL) -> BaseClient:
67+
return boto3_session.client("apigateway", endpoint_url=str(localstack))
68+
69+
6570
@pytest.fixture(scope="session")
6671
def lambda_client(boto3_session: Session, localstack: URL) -> BaseClient:
6772
return boto3_session.client("lambda", endpoint_url=str(localstack))
@@ -123,18 +128,42 @@ def iam_role(iam_client: BaseClient) -> Generator[str]:
123128
}
124129
],
125130
}
131+
dynamodb_policy = {
132+
"Version": "2012-10-17",
133+
"Statement": [
134+
{
135+
"Effect": "Allow",
136+
"Action": [
137+
"dynamodb:GetItem",
138+
"dynamodb:PutItem",
139+
"dynamodb:UpdateItem",
140+
"dynamodb:DeleteItem",
141+
"dynamodb:Scan",
142+
"dynamodb:Query",
143+
],
144+
"Resource": "arn:aws:dynamodb:*:*:table/*",
145+
}
146+
],
147+
}
126148

127-
# Create the IAM Policy
128-
policy = iam_client.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(log_policy))
129-
policy_arn = policy["Policy"]["Arn"]
149+
# Create CloudWatch Logs policy (as before)
150+
log_policy_resp = iam_client.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(log_policy))
151+
log_policy_arn = log_policy_resp["Policy"]["Arn"]
152+
iam_client.attach_role_policy(RoleName=role_name, PolicyArn=log_policy_arn)
130153

131-
# Attach Policy to Role
132-
iam_client.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn)
154+
# Create DynamoDB policy
155+
ddb_policy_resp = iam_client.create_policy(
156+
PolicyName="LambdaDynamoDBPolicy", PolicyDocument=json.dumps(dynamodb_policy)
157+
)
158+
ddb_policy_arn = ddb_policy_resp["Policy"]["Arn"]
159+
iam_client.attach_role_policy(RoleName=role_name, PolicyArn=ddb_policy_arn)
133160

134161
yield role["Role"]["Arn"]
135162

136-
iam_client.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn)
137-
iam_client.delete_policy(PolicyArn=policy_arn)
163+
iam_client.detach_role_policy(RoleName=role_name, PolicyArn=log_policy_arn)
164+
iam_client.delete_policy(PolicyArn=log_policy_arn)
165+
iam_client.detach_role_policy(RoleName=role_name, PolicyArn=ddb_policy_arn)
166+
iam_client.delete_policy(PolicyArn=ddb_policy_arn)
138167
iam_client.delete_role(RoleName=role_name)
139168

140169

@@ -194,6 +223,71 @@ def wait_for_function_active(function_name, lambda_client):
194223
raise FunctionNotActiveError
195224

196225

226+
@pytest.fixture(scope="session")
227+
def configured_api_gateway(api_gateway_client, lambda_client, flask_function: str):
228+
region = lambda_client.meta.region_name
229+
230+
api = api_gateway_client.create_rest_api(name="API Gateway Lambda integration")
231+
rest_api_id = api["id"]
232+
233+
resources = api_gateway_client.get_resources(restApiId=rest_api_id)
234+
root_id = next(item["id"] for item in resources["items"] if item["path"] == "/")
235+
236+
patient_check_res = api_gateway_client.create_resource(
237+
restApiId=rest_api_id, parentId=root_id, pathPart="patient-check"
238+
)
239+
patient_check_id = patient_check_res["id"]
240+
241+
id_res = api_gateway_client.create_resource(restApiId=rest_api_id, parentId=patient_check_id, pathPart="{id}")
242+
resource_id = id_res["id"]
243+
244+
api_gateway_client.put_method(
245+
restApiId=rest_api_id,
246+
resourceId=resource_id,
247+
httpMethod="GET",
248+
authorizationType="NONE",
249+
requestParameters={"method.request.path.id": True},
250+
)
251+
252+
# Integration with actual region
253+
lambda_uri = (
254+
f"arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/"
255+
f"arn:aws:lambda:{region}:000000000000:function:{flask_function}/invocations"
256+
)
257+
api_gateway_client.put_integration(
258+
restApiId=rest_api_id,
259+
resourceId=resource_id,
260+
httpMethod="GET",
261+
type="AWS_PROXY",
262+
integrationHttpMethod="POST",
263+
uri=lambda_uri,
264+
passthroughBehavior="WHEN_NO_MATCH",
265+
)
266+
267+
# Permission with matching region
268+
lambda_client.add_permission(
269+
FunctionName=flask_function,
270+
StatementId="apigateway-access",
271+
Action="lambda:InvokeFunction",
272+
Principal="apigateway.amazonaws.com",
273+
SourceArn=f"arn:aws:execute-api:{region}:000000000000:{rest_api_id}/*/GET/patient-check/*",
274+
)
275+
276+
# Deploy the API
277+
api_gateway_client.create_deployment(restApiId=rest_api_id, stageName="dev")
278+
279+
return {
280+
"rest_api_id": rest_api_id,
281+
"resource_id": resource_id,
282+
"invoke_url": f"http://{rest_api_id}.execute-api.localhost.localstack.cloud:4566/dev/patient-check/{{id}}",
283+
}
284+
285+
286+
@pytest.fixture
287+
def api_gateway_endpoint(configured_api_gateway: dict) -> URL:
288+
return URL(f"http://{configured_api_gateway['rest_api_id']}.execute-api.localhost.localstack.cloud:4566/dev")
289+
290+
197291
@pytest.fixture(scope="session")
198292
def person_table(dynamodb_resource: ServiceResource) -> Generator[Any]:
199293
table = dynamodb_resource.create_table(

tests/integration/lambda/test_app_running_as_lambda.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ def test_install_and_call_lambda_flask(
3434
"routeKey": "GET /",
3535
"rawPath": "/",
3636
"rawQueryString": "",
37-
"headers": {"accept": "application/json", "content-type": "application/json"},
37+
"headers": {
38+
"accept": "application/json",
39+
"content-type": "application/json",
40+
"nhs-login-nhs-number": str(persisted_person),
41+
},
42+
"pathParameters": {"id": str(persisted_person)},
3843
"requestContext": {
3944
"http": {
4045
"sourceIp": "192.0.0.1",
@@ -68,15 +73,19 @@ def test_install_and_call_lambda_flask(
6873

6974

7075
def test_install_and_call_flask_lambda_over_http(
71-
flask_function_url: URL,
7276
persisted_person: NHSNumber,
7377
campaign_config: CampaignConfig, # noqa: ARG001
78+
api_gateway_endpoint: URL,
7479
):
75-
"""Given lambda installed into localstack, run it via http"""
80+
"""Given api-gateway and lambda installed into localstack, run it via http"""
7681
# Given
77-
7882
# When
79-
response = httpx.get(str(flask_function_url / "patient-check" / persisted_person))
83+
invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}"
84+
response = httpx.get(
85+
invoke_url,
86+
headers={"nhs-login-nhs-number": str(persisted_person)},
87+
timeout=10,
88+
)
8089

8190
# Then
8291
assert_that(
@@ -86,18 +95,23 @@ def test_install_and_call_flask_lambda_over_http(
8695

8796

8897
def test_install_and_call_flask_lambda_with_unknown_nhs_number(
89-
flask_function_url: URL,
9098
flask_function: str,
9199
campaign_config: CampaignConfig, # noqa: ARG001
92100
logs_client: BaseClient,
101+
api_gateway_endpoint: URL,
93102
faker: Faker,
94103
):
95104
"""Given lambda installed into localstack, run it via http, with a nonexistent NHS number specified"""
96105
# Given
97106
nhs_number = NHSNumber(faker.nhs_number())
98107

99108
# When
100-
response = httpx.get(str(flask_function_url / "patient-check" / nhs_number))
109+
invoke_url = f"{api_gateway_endpoint}/patient-check/{nhs_number}"
110+
response = httpx.get(
111+
invoke_url,
112+
headers={"nhs-login-nhs-number": str(nhs_number)},
113+
timeout=10,
114+
)
101115

102116
# Then
103117
assert_that(
@@ -136,3 +150,47 @@ def get_log_messages(flask_function: str, logs_client: BaseClient) -> list[str]:
136150
logGroupName=f"/aws/lambda/{flask_function}", logStreamName=log_stream_name, limit=100
137151
)
138152
return [e["message"] for e in log_events["events"]]
153+
154+
155+
def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers(
156+
lambda_client: BaseClient, # noqa:ARG001
157+
persisted_person: NHSNumber,
158+
campaign_config: CampaignConfig, # noqa:ARG001
159+
api_gateway_endpoint: URL,
160+
):
161+
# Given
162+
# When
163+
invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}"
164+
response = httpx.get(
165+
invoke_url,
166+
headers={"nhs-login-nhs-number": str(persisted_person)},
167+
timeout=10,
168+
)
169+
170+
# Then
171+
assert_that(
172+
response,
173+
is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))),
174+
)
175+
176+
177+
def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_results_in_error_response(
178+
lambda_client: BaseClient, # noqa:ARG001
179+
persisted_person: NHSNumber,
180+
campaign_config: CampaignConfig, # noqa:ARG001
181+
api_gateway_endpoint: URL,
182+
):
183+
# Given
184+
# When
185+
invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}"
186+
response = httpx.get(
187+
invoke_url,
188+
headers={"nhs-login-nhs-number": f"123{persisted_person!s}"},
189+
timeout=10,
190+
)
191+
192+
# Then
193+
assert_that(
194+
response,
195+
is_response().with_status_code(HTTPStatus.FORBIDDEN).and_body("NHS number mismatch"),
196+
)

0 commit comments

Comments
 (0)