diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index 40c1ddcdd..47308e085 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -135,3 +135,19 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) + +CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) + +CONSUMER_HAS_NO_CAMPAIGN_MAPPING = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index cd213287a..40416e5ca 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -7,12 +7,13 @@ from flask.typing import ResponseReturnValue from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_ID_NOT_PROVIDED_ERROR, INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, NHS_NUMBER_MISMATCH_ERROR, ) -from eligibility_signposting_api.config.constants import NHS_NUMBER_HEADER +from eligibility_signposting_api.config.constants import CONSUMER_ID, NHS_NUMBER_HEADER logger = logging.getLogger(__name__) @@ -56,6 +57,14 @@ def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003 + consumer_id = str(request.headers.get(CONSUMER_ID)) + + if not consumer_id: + message = "You are not authorised to request" + return CONSUMER_ID_NOT_PROVIDED_ERROR.log_and_generate_response( + log_message=message, diagnostics=message + ) + path_nhs_number = str(kwargs.get("nhs_number")) header_nhs_no = str(request.headers.get(NHS_NUMBER_HEADER)) diff --git a/src/eligibility_signposting_api/config/config.py b/src/eligibility_signposting_api/config/config.py index 6be1840aa..52f3111cc 100644 --- a/src/eligibility_signposting_api/config/config.py +++ b/src/eligibility_signposting_api/config/config.py @@ -22,6 +22,7 @@ def config() -> dict[str, Any]: person_table_name = TableName(os.getenv("PERSON_TABLE_NAME", "test_eligibility_datastore")) rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket")) + consumer_mapping_bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket")) audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) hashing_secret_name = HashSecretName(os.getenv("HASHING_SECRET_NAME", "test_secret")) aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1")) @@ -41,6 +42,7 @@ def config() -> dict[str, Any]: "s3_endpoint": None, "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, + "consumer_mapping_bucket_name": consumer_mapping_bucket_name, "firehose_endpoint": None, "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "enable_xray_patching": enable_xray_patching, @@ -59,6 +61,7 @@ def config() -> dict[str, Any]: "s3_endpoint": URL(os.getenv("S3_ENDPOINT", local_stack_endpoint)), "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, + "consumer_mapping_bucket_name": consumer_mapping_bucket_name, "firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)), "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "enable_xray_patching": enable_xray_patching, diff --git a/src/eligibility_signposting_api/config/constants.py b/src/eligibility_signposting_api/config/constants.py index 3aa45fd35..bdc49e307 100644 --- a/src/eligibility_signposting_api/config/constants.py +++ b/src/eligibility_signposting_api/config/constants.py @@ -3,4 +3,5 @@ URL_PREFIX = "patient-check" RULE_STOP_DEFAULT = False NHS_NUMBER_HEADER = "nhs-login-nhs-number" +CONSUMER_ID = "consumer-id" ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"] diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py new file mode 100644 index 000000000..8e86d7e46 --- /dev/null +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -0,0 +1,12 @@ +from typing import NewType + +from pydantic import RootModel + +from eligibility_signposting_api.model.campaign_config import CampaignID + +ConsumerId = NewType("ConsumerId", str) + + +class ConsumerMapping(RootModel[dict[ConsumerId, list[CampaignID]]]): + def get(self, key: ConsumerId, default: list[CampaignID] | None = None) -> list[CampaignID] | None: + return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py new file mode 100644 index 000000000..140ac74c4 --- /dev/null +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -0,0 +1,32 @@ +import json +from typing import Annotated, NewType + +from botocore.client import BaseClient +from wireup import Inject, service + +from eligibility_signposting_api.model.campaign_config import CampaignID +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping + +BucketName = NewType("BucketName", str) + + +@service +class ConsumerMappingRepo: + """Repository class for Campaign Rules, which we can use to calculate a person's eligibility for vaccination. + + These rules are stored as JSON files in AWS S3.""" + + def __init__( + self, + s3_client: Annotated[BaseClient, Inject(qualifier="s3")], + bucket_name: Annotated[BucketName, Inject(param="consumer_mapping_bucket_name")], + ) -> None: + super().__init__() + self.s3_client = s3_client + self.bucket_name = bucket_name + + def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + body = response["Body"].read() + return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 79934e174..74d3c659c 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -3,7 +3,10 @@ from wireup import service from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator logger = logging.getLogger(__name__) @@ -17,18 +20,24 @@ class InvalidQueryParamError(Exception): pass +class NoPermittedCampaignsError(Exception): + pass + + @service class EligibilityService: def __init__( self, person_repo: PersonRepo, campaign_repo: CampaignRepo, + consumer_mapping_repo: ConsumerMappingRepo, calculator_factory: calculator.EligibilityCalculatorFactory, ) -> None: super().__init__() self.person_repo = person_repo self.campaign_repo = campaign_repo self.calculator_factory = calculator_factory + self.consumer_mapping = consumer_mapping_repo def get_eligibility_status( self, @@ -36,16 +45,33 @@ def get_eligibility_status( include_actions: str, conditions: list[str], category: str, + consumer_id: str, ) -> eligibility_status.EligibilityStatus: """Calculate a person's eligibility for vaccination given an NHS number.""" if nhs_number: try: person_data = self.person_repo.get_eligibility_data(nhs_number) - campaign_configs = list(self.campaign_repo.get_campaign_configs()) except NotFoundError as e: raise UnknownPersonError from e else: - calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, campaign_configs) + campaign_configs: list[CampaignConfig] = list(self.campaign_repo.get_campaign_configs()) + permitted_campaign_configs = self.__collect_permitted_campaign_configs( + campaign_configs, ConsumerId(consumer_id) + ) + calc: calculator.EligibilityCalculator = self.calculator_factory.get( + person_data, permitted_campaign_configs + ) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover + + def __collect_permitted_campaign_configs( + self, campaign_configs: list[CampaignConfig], consumer_id: ConsumerId + ) -> list[CampaignConfig]: + permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(ConsumerId(consumer_id)) + if permitted_campaign_ids: + permitted_campaign_configs: list[CampaignConfig] = [ + campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids + ] + return permitted_campaign_configs + raise NoPermittedCampaignsError diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index eb2b706ea..bf6fe1f36 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -11,11 +11,16 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR +from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_HAS_NO_CAMPAIGN_MAPPING, + NHS_NUMBER_NOT_FOUND_ERROR, +) from eligibility_signposting_api.common.request_validator import validate_request_params -from eligibility_signposting_api.config.constants import URL_PREFIX +from eligibility_signposting_api.config.constants import CONSUMER_ID, URL_PREFIX +from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.response_model import eligibility_response from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion @@ -47,23 +52,36 @@ def check_eligibility( nhs_number: NHSNumber, eligibility_service: Injected[EligibilityService], audit_service: Injected[AuditService] ) -> ResponseReturnValue: logger.info("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) + + query_params = _get_or_default_query_params() + consumer_id = _get_consumer_id_from_headers() + try: - query_params = get_or_default_query_params() eligibility_status = eligibility_service.get_eligibility_status( nhs_number, query_params["includeActions"], query_params["conditions"], query_params["category"], + consumer_id, ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) + except NoPermittedCampaignsError: + return handle_no_permitted_campaigns_for_the_consumer_error(consumer_id) else: response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) return make_response(response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK) -def get_or_default_query_params() -> dict[str, Any]: +def _get_consumer_id_from_headers() -> ConsumerId: + """ + @validate_request_params() ensures the consumer ID is never null at this stage. + """ + return ConsumerId(request.headers.get(CONSUMER_ID, "")) + + +def _get_or_default_query_params() -> dict[str, Any]: default_query_params = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"} if not request.args: @@ -102,6 +120,13 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: ) +def handle_no_permitted_campaigns_for_the_consumer_error(consumer_id: ConsumerId) -> ResponseReturnValue: + diagnostics = f"Consumer ID '{consumer_id}' was not recognised by the Eligibility Signposting API" + return CONSUMER_HAS_NO_CAMPAIGN_MAPPING.log_and_generate_response( + log_message=diagnostics, diagnostics=diagnostics, location_param="id" + ) + + def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility_response.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index bf62de900..2793ea032 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - + id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1edf35179..0a168ff0f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -21,6 +21,7 @@ from eligibility_signposting_api.model.campaign_config import ( AvailableAction, CampaignConfig, + CampaignID, EndDate, RuleCode, RuleEntry, @@ -30,6 +31,7 @@ StartDate, StatusText, ) +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName from eligibility_signposting_api.repos import SecretRepo from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -661,6 +663,14 @@ def rules_bucket(s3_client: BaseClient) -> Generator[BucketName]: s3_client.delete_bucket(Bucket=bucket_name) +@pytest.fixture(scope="session") +def consumer_mapping_bucket(s3_client: BaseClient) -> Generator[BucketName]: + bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket")) + s3_client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={"LocationConstraint": AWS_REGION}) + yield bucket_name + s3_client.delete_bucket(Bucket=bucket_name) + + @pytest.fixture(scope="session") def audit_bucket(s3_client: BaseClient) -> Generator[BucketName]: bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) @@ -719,6 +729,45 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + +@pytest.fixture(scope="class") +def consumer_mapping_with_various_targets( + s3_client: BaseClient, consumer_mapping_bucket: BucketName +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("campaign_start_date"), + CampaignID("campaign_start_date_plus_one_day"), + CampaignID("campaign_today"), + CampaignID("campaign_tomorrow"), + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + @pytest.fixture def campaign_config_with_rules_having_rule_code( s3_client: BaseClient, rules_bucket: BucketName diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 4e5cdbfb8..14bb094dd 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -13,7 +13,9 @@ has_key, ) +from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) @@ -25,10 +27,11 @@ def test_nhs_number_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) @@ -80,9 +83,10 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts)} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -124,9 +128,10 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -168,9 +173,10 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -218,8 +224,9 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -269,9 +276,10 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -323,9 +331,10 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -367,9 +376,10 @@ def test_not_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -417,9 +427,10 @@ def test_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -471,9 +482,10 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts)} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -509,9 +521,10 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -547,9 +560,10 @@ def test_not_actionable( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -591,9 +605,10 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -637,9 +652,10 @@ def test_actionable_no_actions( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=N", headers=headers) @@ -711,9 +727,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_code: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -761,9 +778,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -805,3 +823,23 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ) ), ) + + +class TestEligibilityResponseWhenConsumerHasNoMapping: + def test_empty_response_when_no_campaign_mapped_for_the_consumer( + self, + client: FlaskClient, + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 + ): + # Given + consumer_id_not_having_mapping = "23-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} + + # When + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.FORBIDDEN)) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 9c473696a..f6725f70c 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -23,7 +23,9 @@ ) from yarl import URL +from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -35,6 +37,7 @@ def test_install_and_call_lambda_flask( flask_function: str, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 ): """Given lambda installed into localstack, run it via boto3 lambda client""" # Given @@ -49,6 +52,7 @@ def test_install_and_call_lambda_flask( "accept": "application/json", "content-type": "application/json", "nhs-login-nhs-number": str(persisted_person), + CONSUMER_ID: "23-mic7heal-jor6don", }, "pathParameters": {"id": str(persisted_person)}, "requestContext": { @@ -86,6 +90,7 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): """Given api-gateway and lambda installed into localstack, run it via http""" @@ -94,7 +99,7 @@ def test_install_and_call_flask_lambda_over_http( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(persisted_person)}, + headers={"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -105,10 +110,11 @@ def test_install_and_call_flask_lambda_over_http( ) -def test_install_and_call_flask_lambda_with_unknown_nhs_number( +def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 logs_client: BaseClient, api_gateway_endpoint: URL, ): @@ -120,7 +126,7 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( invoke_url = f"{api_gateway_endpoint}/patient-check/{nhs_number}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(nhs_number)}, + headers={"nhs-login-nhs-number": str(nhs_number), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -182,6 +188,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, campaign_config: CampaignConfig, + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -195,6 +202,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -371,6 +379,7 @@ def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): # Given @@ -378,7 +387,7 @@ def test_validation_of_query_params_when_all_are_valid( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": persisted_person}, + headers={"nhs-login-nhs-number": persisted_person, CONSUMER_ID: "23-mic7heal-jor6don"}, params={"category": "VACCINATIONS", "conditions": "COVID19", "includeActions": "N"}, timeout=10, ) @@ -411,6 +420,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, multiple_campaign_configs: list[CampaignConfig], + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -420,6 +430,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -554,6 +565,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, inactive_iteration_config: list[CampaignConfig], # noqa:ARG001 + consumer_mapping_with_various_targets: ConsumerMapping, # noqa:ARG001 api_gateway_endpoint: URL, ): invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" @@ -561,6 +573,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -590,6 +603,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_tokens: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa:ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -599,7 +613,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data)}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -640,6 +654,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_invalid_tokens: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -649,7 +664,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data)}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py new file mode 100644 index 000000000..df15f1a10 --- /dev/null +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -0,0 +1,57 @@ +import json +import pytest +from unittest.mock import MagicMock + +from eligibility_signposting_api.model.consumer_mapping import ConsumerId +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo, BucketName + + +class TestConsumerMappingRepo: + @pytest.fixture + def mock_s3_client(self): + return MagicMock() + + @pytest.fixture + def repo(self, mock_s3_client): + return ConsumerMappingRepo( + s3_client=mock_s3_client, + bucket_name=BucketName("test-bucket") + ) + + def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): + # Given + consumer_id = "user-123" + expected_campaigns = ["flu-2024", "covid-2024"] + mapping_data = { + consumer_id: expected_campaigns + } + + mock_s3_client.list_objects.return_value = { + "Contents": [{"Key": "mappings.json"}] + } + + body_json = json.dumps(mapping_data).encode("utf-8") + mock_s3_client.get_object.return_value = { + "Body": MagicMock(read=lambda: body_json) + } + + # When + result = repo.get_permitted_campaign_ids(ConsumerId(consumer_id)) + + # Then + assert result == expected_campaigns + mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket") + mock_s3_client.get_object.assert_called_once_with( + Bucket="test-bucket", + Key="mappings.json" + ) + + def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s3_client): + # Setup data where the consumer_id doesn't exist + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} + body_json = json.dumps({"other-user": ["camp-1"]}).encode("utf-8") + mock_s3_client.get_object.return_value = {"Body": MagicMock(read=lambda: body_json)} + + result = repo.get_permitted_campaign_ids(ConsumerId("missing-user")) + + assert result is None diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 504888f12..793efc355 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,23 +3,44 @@ import pytest from hamcrest import assert_that, empty +from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignConfig from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from tests.fixtures.matchers.eligibility import is_eligibility_status +@pytest.fixture +def mock_repos(): + return { + "person": MagicMock(spec=PersonRepo), + "campaign": MagicMock(spec=CampaignRepo), + "consumer": MagicMock(spec=ConsumerMappingRepo), + "factory": MagicMock(spec=EligibilityCalculatorFactory) + } + +@pytest.fixture +def service(mock_repos): + return EligibilityService( + mock_repos["person"], + mock_repos["campaign"], + mock_repos["consumer"], + mock_repos["factory"] + ) def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) campaign_repo = MagicMock(spec=CampaignRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) person_repo.get_eligibility = MagicMock(return_value=[]) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, consumer_mapping_repo, EligibilityCalculatorFactory()) # When actual = service.get_eligibility_status( - NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL" + NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL", consumer_id="test_consumer_id" ) # Then @@ -30,9 +51,63 @@ def test_eligibility_service_for_nonexistent_nhs_number(): # Given person_repo = MagicMock(spec=PersonRepo) campaign_repo = MagicMock(spec=CampaignRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) person_repo.get_eligibility_data = MagicMock(side_effect=NotFoundError) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, consumer_mapping_repo, EligibilityCalculatorFactory()) # When with pytest.raises(UnknownPersonError): - service.get_eligibility_status(NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL") + service.get_eligibility_status( + NHSNumber("1234567890"), + include_actions="Y", + conditions=["ALL"], + category="ALL", + consumer_id="test_consumer_id", + ) + + +def test_get_eligibility_status_filters_permitted_campaigns(service, mock_repos): + """Tests that ONLY permitted campaigns reach the calculator factory.""" + # Given + nhs_number = NHSNumber("1234567890") + person_data = {"age": 65, "vulnerable": True} + mock_repos["person"].get_eligibility_data.return_value = person_data + + # Available campaigns in system + camp_a = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_A")) + camp_b = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_B")) + mock_repos["campaign"].get_campaign_configs.return_value = [camp_a, camp_b] + + # Consumer is only permitted to see CAMP_B + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [CampaignID("CAMP_B")] + + # Mock calculator behavior + mock_calc = MagicMock() + mock_repos["factory"].get.return_value = mock_calc + mock_calc.get_eligibility_status.return_value = "eligible_result" + + # When + result = service.get_eligibility_status(nhs_number, "Y", ["FLU"], "G1", "consumer_xyz") + + # Then + # Verify the factory was called ONLY with camp_b + mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) + assert result == "eligible_result" + +def test_raises_no_permitted_campaigns_error(service, mock_repos): + """Tests the scenario where the consumer mapping exists but returns nothing.""" + mock_repos["person"].get_eligibility_data.return_value = {"data": "exists"} + mock_repos["campaign"].get_campaign_configs.return_value = [MagicMock()] + + # Consumer has no permitted IDs mapped + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [] + + with pytest.raises(NoPermittedCampaignsError): + service.get_eligibility_status(NHSNumber("1"), "Y", [], "", "bad_consumer") + +def test_raises_unknown_person_error_on_repo_not_found(service, mock_repos): + """Tests that NotFoundError from repo is translated to UnknownPersonError.""" + mock_repos["person"].get_eligibility_data.side_effect = NotFoundError + + with pytest.raises(UnknownPersonError): + service.get_eligibility_status(NHSNumber("999"), "Y", [], "", "any") diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 5c323a7b2..fedc6d84b 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -28,11 +28,12 @@ UrlLink, ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.eligibility import ( + _get_or_default_query_params, build_actions, build_eligibility_cohorts, build_suitability_results, - get_or_default_query_params, ) from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( @@ -60,6 +61,7 @@ def get_eligibility_status( _include_actions: str, _conditions: list[str], _category: str, + _consumer_id: str, ) -> EligibilityStatus: return EligibilityStatusFactory.build() @@ -74,6 +76,7 @@ def get_eligibility_status( _include_actions: str, _conditions: list[str], _category: str, + _consumer_id: str, ) -> EligibilityStatus: raise UnknownPersonError @@ -91,6 +94,20 @@ def get_eligibility_status( ) -> EligibilityStatus: raise ValueError +class FakeNoPermittedCampaignsService(EligibilityService): + def __init__(self): + pass + + def get_eligibility_status( + self, + _nhs_number: NHSNumber, + _include_actions: str, + _conditions: list[str], + _category: str, + _consumer_id: str, + ) -> EligibilityStatus: + # Simulate the new error scenario + raise NoPermittedCampaignsError def test_security_headers_present_on_successful_response(app: Flask, client: FlaskClient): """Test that security headers are present on successful eligibility check response.""" @@ -100,7 +117,7 @@ def test_security_headers_present_on_successful_response(app: Flask, client: Fla get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # When - headers = {"nhs-login-nhs-number": "9876543210"} + headers = {"nhs-login-nhs-number": "9876543210", "Consumer-Id": "test_consumer_id"} response = client.get("/patient-check/9876543210", headers=headers) # Then @@ -507,7 +524,7 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): def test_get_or_default_query_params_with_no_args(app: Flask): with app.test_request_context("/patient-check"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"} @@ -516,7 +533,7 @@ def test_get_or_default_query_params_with_no_args(app: Flask): def test_get_or_default_query_params_with_all_args(app: Flask): with app.test_request_context("/patient-check?includeActions=Y&category=VACCINATIONS&conditions=FLU"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "Y", "category": "VACCINATIONS", "conditions": ["FLU"]} @@ -525,7 +542,7 @@ def test_get_or_default_query_params_with_all_args(app: Flask): def test_get_or_default_query_params_with_partial_args(app: Flask): with app.test_request_context("/patient-check?includeActions=N"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "N", "category": "ALL", "conditions": ["ALL"]} @@ -534,13 +551,13 @@ def test_get_or_default_query_params_with_partial_args(app: Flask): def test_get_or_default_query_params_with_lowercase_y(app: Flask): with app.test_request_context("/patient-check?includeActions=y"): - result = get_or_default_query_params() + result = _get_or_default_query_params() assert_that(result["includeActions"], is_("Y")) def test_get_or_default_query_params_missing_include_actions(app: Flask): with app.test_request_context("/patient-check?category=SCREENING&conditions=COVID19,FLU"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "Y", "category": "SCREENING", "conditions": ["COVID19", "FLU"]} @@ -581,3 +598,74 @@ def test_status_endpoint(app: Flask, client: FlaskClient): ) ), ) + + +def test_no_permitted_campaigns_for_consumer_error(app: Flask, client: FlaskClient): + """ + Tests that NoPermittedCampaignsError is caught and returns + the correct FHIR OperationOutcome with FORBIDDEN status. + """ + # Given + with ( + get_app_container(app).override.service(EligibilityService, new=FakeNoPermittedCampaignsService()), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): + headers = { + "nhs-login-nhs-number": "9876543210", + "Consumer-Id": "unrecognized_consumer" + } + + # When + response = client.get("/patient-check/9876543210", headers=headers) + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.FORBIDDEN) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) + .and_text( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="forbidden", + diagnostics="Consumer ID 'unrecognized_consumer' was not recognised by the Eligibility Signposting API" + ) + ) + ) + ) + ) + ) + + +def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): + """ + Verifies that the consumer ID from the header is actually passed + to the eligibility service call. + """ + # Given + mock_service = MagicMock(spec=EligibilityService) + mock_service.get_eligibility_status.return_value = EligibilityStatusFactory.build() + + with ( + get_app_container(app).override.service(EligibilityService, new=mock_service), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): + headers = { + "nhs-login-nhs-number": "1234567890", + "Consumer-Id": "specific_consumer_123" + } + + # When + client.get("/patient-check/1234567890", headers=headers) + + # Then + # Verify the 5th positional argument or the keyword argument 'consumer_id' + mock_service.get_eligibility_status.assert_called_once() + args, kwargs = mock_service.get_eligibility_status.call_args + + # Check that 'specific_consumer_123' was the consumer_id passed + assert args[4] == "specific_consumer_123"