Skip to content

Commit dc483f3

Browse files
nhs number validation fix (#549)
* nhs number validation fix * nhs number validation fix * integration tests * integration tests * integration tests * integration tests * path validation fixed
1 parent b7715ae commit dc483f3

5 files changed

Lines changed: 134 additions & 28 deletions

File tree

src/eligibility_signposting_api/common/api_error_response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def log_and_generate_response(
128128
fhir_display_message="An unexpected internal server error occurred.",
129129
)
130130

131-
NHS_NUMBER_MISMATCH_ERROR = APIErrorResponse(
131+
NHS_NUMBER_ERROR = APIErrorResponse(
132132
status_code=HTTPStatus.FORBIDDEN,
133133
fhir_issue_code=FHIRIssueCode.FORBIDDEN,
134134
fhir_issue_severity=FHIRIssueSeverity.ERROR,

src/eligibility_signposting_api/common/request_validator.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
INVALID_CATEGORY_ERROR,
1111
INVALID_CONDITION_FORMAT_ERROR,
1212
INVALID_INCLUDE_ACTIONS_ERROR,
13-
NHS_NUMBER_MISMATCH_ERROR,
13+
NHS_NUMBER_ERROR,
1414
)
1515
from eligibility_signposting_api.config.constants import NHS_NUMBER_HEADER
1616

@@ -39,17 +39,7 @@ def validate_query_params(query_params: dict[str, str]) -> tuple[bool, ResponseR
3939
return True, None
4040

4141

42-
def validate_nhs_number(path_nhs: str | None, header_nhs: str | None) -> bool:
43-
logger.info("NHS numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs})
44-
45-
if not path_nhs:
46-
logger.error("NHS number is not present in path", extra={"path_nhs": path_nhs})
47-
return False
48-
49-
if not header_nhs: # Not a validation error
50-
logger.info("NHS number is not present in header", extra={"header_nhs": header_nhs, "path_nhs": path_nhs})
51-
return True
52-
42+
def validate_nhs_number_in_header(path_nhs: str | None, header_nhs: str | None) -> bool:
5343
if header_nhs != path_nhs:
5444
logger.error("NHS number mismatch", extra={"header_nhs": header_nhs, "path_nhs": path_nhs})
5545
return False
@@ -60,12 +50,22 @@ def validate_request_params() -> Callable:
6050
def decorator(func: Callable) -> Callable:
6151
@wraps(func)
6252
def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003
63-
path_nhs_number = str(kwargs.get("nhs_number"))
64-
header_nhs_no = str(request.headers.get(NHS_NUMBER_HEADER))
53+
path_nhs_number = str(kwargs.get("nhs_number")) if kwargs.get("nhs_number") else None
6554

66-
if not validate_nhs_number(path_nhs_number, header_nhs_no):
55+
if not path_nhs_number:
6756
message = "You are not authorised to request information for the supplied NHS Number"
68-
return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response(log_message=message, diagnostics=message)
57+
return NHS_NUMBER_ERROR.log_and_generate_response(log_message=message, diagnostics=message)
58+
59+
if NHS_NUMBER_HEADER in request.headers:
60+
header_nhs_no = request.headers.get(NHS_NUMBER_HEADER)
61+
logger.info(
62+
"NHS numbers from the request", extra={"header_nhs": header_nhs_no, "path_nhs": path_nhs_number}
63+
)
64+
if not validate_nhs_number_in_header(path_nhs_number, header_nhs_no):
65+
message = "You are not authorised to request information for the supplied NHS Number"
66+
return NHS_NUMBER_ERROR.log_and_generate_response(log_message=message, diagnostics=message)
67+
else:
68+
logger.info("NHS numbers from the request", extra={"header_nhs": None, "path_nhs": path_nhs_number})
6969

7070
query_params = request.args
7171
if query_params:

tests/integration/in_process/test_eligibility_endpoint.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from http import HTTPStatus
22

3+
import pytest
34
from botocore.client import BaseClient
45
from brunns.matchers.data import json_matching as is_json_that
56
from brunns.matchers.werkzeug import is_werkzeug_response as is_response
@@ -39,6 +40,77 @@ def test_nhs_number_given(
3940
is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_key("processedSuggestions"))),
4041
)
4142

43+
@pytest.mark.parametrize(
44+
"headers",
45+
[
46+
{}, # header missing entirely, valid
47+
],
48+
)
49+
def test_nhs_number_given_in_path_but_no_nhs_number_header_present(
50+
self,
51+
client: FlaskClient,
52+
persisted_person: NHSNumber,
53+
campaign_config: CampaignConfig, # noqa: ARG002
54+
secretsmanager_client: BaseClient, # noqa: ARG002
55+
headers: dict,
56+
):
57+
# Given
58+
# When
59+
response = client.get(f"/patient-check/{persisted_person}", headers=headers)
60+
61+
# Then
62+
assert_that(
63+
response,
64+
is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_key("processedSuggestions"))),
65+
)
66+
67+
@pytest.mark.parametrize(
68+
"headers",
69+
[
70+
{"nhs-login-nhs-number": None}, # header present but empty, invalid
71+
{"nhs-login-nhs-number": ""}, # header present but blank, invalid
72+
],
73+
)
74+
def test_nhs_number_in_path_and_header_present_but_empty_or_none(
75+
self,
76+
headers: dict,
77+
client: FlaskClient,
78+
persisted_person: NHSNumber,
79+
campaign_config: CampaignConfig, # noqa: ARG002
80+
secretsmanager_client: BaseClient, # noqa: ARG002
81+
):
82+
# When
83+
response = client.get(f"/patient-check/{persisted_person}", headers=headers)
84+
85+
# Then
86+
assert_that(
87+
response,
88+
is_response()
89+
.with_status_code(HTTPStatus.FORBIDDEN)
90+
.and_text(is_json_that(has_entries(resourceType="OperationOutcome"))),
91+
)
92+
93+
def test_nhs_number_given_but_header_nhs_number_doesnt_match(
94+
self,
95+
client: FlaskClient,
96+
persisted_person: NHSNumber,
97+
campaign_config: CampaignConfig, # noqa: ARG002
98+
secretsmanager_client: BaseClient, # noqa: ARG002
99+
):
100+
# Given
101+
headers = {"nhs-login-nhs-number": f"123{persisted_person!s}"}
102+
103+
# When
104+
response = client.get(f"/patient-check/{persisted_person}", headers=headers)
105+
106+
# Then
107+
assert_that(
108+
response,
109+
is_response()
110+
.with_status_code(HTTPStatus.FORBIDDEN)
111+
.and_text(is_json_that(has_entries(resourceType="OperationOutcome"))),
112+
)
113+
42114
def test_no_nhs_number_given(self, client: FlaskClient):
43115
# Given
44116

tests/integration/lambda/test_app_running_as_lambda.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu
321321
)
322322

323323

324-
def test_given_nhs_number_not_present_in_headers_results_in_error_response(
324+
def test_given_nhs_number_not_present_in_headers_results_in_valid_for_application_restricted_users(
325325
lambda_client: BaseClient, # noqa:ARG001
326326
persisted_person: NHSNumber,
327327
campaign_config: CampaignConfig, # noqa:ARG001
@@ -335,12 +335,32 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response(
335335
timeout=10,
336336
)
337337

338+
assert_that(
339+
response,
340+
is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))),
341+
)
342+
343+
344+
def test_given_nhs_number_key_present_in_headers_have_no_value_results_in_error_response(
345+
lambda_client: BaseClient, # noqa:ARG001
346+
persisted_person: NHSNumber,
347+
campaign_config: CampaignConfig, # noqa:ARG001
348+
api_gateway_endpoint: URL,
349+
):
350+
# Given
351+
# When
352+
invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}"
353+
response = httpx.get(
354+
invoke_url,
355+
headers={"nhs-login-nhs-number": ""},
356+
timeout=10,
357+
)
358+
338359
# Then
339360
assert_that(
340361
response,
341362
is_response()
342363
.with_status_code(HTTPStatus.FORBIDDEN)
343-
.with_headers(has_entries({"Content-Type": "application/fhir+json"}))
344364
.and_body(
345365
is_json_that(
346366
has_entries(

tests/unit/common/test_request_validator.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,15 @@ class TestValidateNHSNumber:
2020
@pytest.mark.parametrize(
2121
("path_nhs", "header_nhs", "expected_result", "expected_log_msg"),
2222
[
23-
(None, None, False, "NHS number is not present in path"),
24-
("1234567890", None, True, None),
25-
(None, "1234567890", False, "NHS number is not present in path"),
23+
("1234567890", None, False, "NHS number mismatch"),
24+
("1234567890", "", False, "NHS number mismatch"),
2625
("1234567890", "0987654321", False, "NHS number mismatch"),
2726
("1234567890", "1234567890", True, None),
2827
],
2928
)
30-
def test_validate_nhs_number(self, path_nhs, header_nhs, expected_result, expected_log_msg, caplog):
29+
def test_validate_nhs_number_in_header(self, path_nhs, header_nhs, expected_result, expected_log_msg, caplog):
3130
with caplog.at_level(logging.ERROR):
32-
result = request_validator.validate_nhs_number(path_nhs, header_nhs)
31+
result = request_validator.validate_nhs_number_in_header(path_nhs, header_nhs)
3332

3433
assert result == expected_result
3534

@@ -40,15 +39,22 @@ def test_validate_nhs_number(self, path_nhs, header_nhs, expected_result, expect
4039

4140

4241
class TestValidateRequestParams:
43-
def test_validate_request_params_success(self, app, caplog):
42+
@pytest.mark.parametrize(
43+
"headers",
44+
[
45+
{}, # header missing entirely - request from application restricted consumer
46+
{"nhs-login-nhs-number": "1234567890"}, # valid request from consumer
47+
],
48+
)
49+
def test_validate_request_params_success(self, headers, app, caplog):
4450
mock_api = MagicMock(return_value="success")
4551

4652
decorator = request_validator.validate_request_params()
4753
dummy_route = decorator(mock_api)
4854

4955
with app.test_request_context(
5056
"/dummy?id=1234567890",
51-
headers={"nhs-login-nhs-number": "1234567890"},
57+
headers=headers,
5258
method="GET",
5359
):
5460
with caplog.at_level(logging.INFO):
@@ -58,15 +64,23 @@ def test_validate_request_params_success(self, app, caplog):
5864
assert any("NHS numbers from the request" in record.message for record in caplog.records)
5965
assert not any(record.levelname == "ERROR" for record in caplog.records)
6066

61-
def test_validate_request_params_nhs_mismatch(self, app, caplog):
67+
@pytest.mark.parametrize(
68+
"headers",
69+
[
70+
{"nhs-login-nhs-number": None}, # not valid
71+
{"nhs-login-nhs-number": ""}, # not valid
72+
{"nhs-login-nhs-number": "9834567890"}, # not valid, due to mismatch
73+
],
74+
)
75+
def test_validate_request_params_nhs_mismatch(self, headers, app, caplog):
6276
mock_api = MagicMock()
6377

6478
decorator = request_validator.validate_request_params()
6579
dummy_route = decorator(mock_api)
6680

6781
with app.test_request_context(
6882
"/dummy?id=1234567890",
69-
headers={"nhs-login-nhs-number": "0987654321"},
83+
headers=headers,
7084
method="GET",
7185
):
7286
with caplog.at_level(logging.INFO):

0 commit comments

Comments
 (0)