diff --git a/Makefile b/Makefile index 40b5f7fe..e5ad5732 100644 --- a/Makefile +++ b/Makefile @@ -54,11 +54,21 @@ publish: # Publish the project artefact @Pipeline # TODO: Implement the artefact publishing step deploy: clean build # Deploy the project artefact to the target environment @Pipeline - @if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ + @PROVIDER_STRING="" ; \ + if [[ -n "$${STUB_PROVIDER}" ]]; then \ + PROVIDER_STRING="$${PROVIDER_STRING} -e STUB_PROVIDER=$${STUB_PROVIDER}" ; \ + fi ; \ + if [[ -n "$${STUB_PDS}" ]]; then \ + PROVIDER_STRING="$${PROVIDER_STRING} -e STUB_PDS=$${STUB_PDS}" ; \ + fi ; \ + if [[ -n "$${STUB_SDS}" ]]; then \ + PROVIDER_STRING="$${PROVIDER_STRING} -e STUB_SDS=$${STUB_SDS}" ; \ + fi ; \ + if [[ -n "$${IN_BUILD_CONTAINER}" ]]; then \ echo "Starting using local docker network ..." ; \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local -d ${IMAGE_NAME} ; \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 --network gateway-local $${PROVIDER_STRING} -d ${IMAGE_NAME} ; \ else \ - $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 -d ${IMAGE_NAME} ; \ + $(docker) run --platform linux/amd64 --name gateway-api -p 5000:8080 $${PROVIDER_STRING} -d ${IMAGE_NAME} ; \ fi clean:: stop # Clean-up project resources (main) @Operations diff --git a/gateway-api/poetry.lock b/gateway-api/poetry.lock index 3c5a3418..296b64cd 100644 --- a/gateway-api/poetry.lock +++ b/gateway-api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "anyio" @@ -753,7 +753,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format\""} idna = {version = "*", optional = true, markers = "extra == \"format\""} isoduration = {version = "*", optional = true, markers = "extra == \"format\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format\""} -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format\""} rfc3987 = {version = "*", optional = true, markers = "extra == \"format\""} @@ -1494,6 +1494,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.11.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, + {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] + [[package]] name = "pyrate-limiter" version = "3.9.0" @@ -2424,5 +2442,5 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" -python-versions = ">3.13,<4.0.0" -content-hash = "a452bd22e2386a3ff58b4c7a5ac2cb571de9e3d49a4fbc161ffd3aafa2a7bf44" +python-versions = ">=3.14,<4.0.0" +content-hash = "161eb0c3f2fa94f8b8a90196db766f1c7ce006eb698852d3bd92adfed98455d1" diff --git a/gateway-api/pyproject.toml b/gateway-api/pyproject.toml index a841d21e..8330ee5d 100644 --- a/gateway-api/pyproject.toml +++ b/gateway-api/pyproject.toml @@ -6,13 +6,14 @@ authors = [ {name = "Your Name", email = "you@example.com"} ] readme = "README.md" -requires-python = ">3.13,<4.0.0" +requires-python = ">=3.14,<4.0.0" [tool.poetry.dependencies] clinical-data-common = { git = "https://github.com/NHSDigital/clinical-data-common.git", tag = "v0.1.0" } flask = "^3.1.2" types-flask = "^1.1.6" requests = "^2.32.5" +pyjwt = "^2.11.0" [tool.poetry] packages = [{include = "gateway_api", from = "src"}, diff --git a/gateway-api/scripts/call_gateway.py b/gateway-api/scripts/call_gateway.py new file mode 100644 index 00000000..1f9ca25e --- /dev/null +++ b/gateway-api/scripts/call_gateway.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import sys +from uuid import uuid4 + +import requests + + +def main() -> None: + # Parse command-line arguments + parser = argparse.ArgumentParser( + description="POST request to GPC getstructuredrecord endpoint" + ) + parser.add_argument( + "nhs_number", help="NHS number to search for (e.g., 9690937278)" + ) + args = parser.parse_args() + + # Check if BASE_URL is set + base_url = os.environ.get("BASE_URL") + if not base_url: + print("Error: BASE_URL environment variable is not set") + sys.exit(1) + + # Endpoint URL + url = f"{base_url}/patient/$gpc.getstructuredrecord" + + # Request headers + headers = { + "Content-Type": "application/fhir+json", + "Accept": "application/fhir+json", + "Ssp-TraceID": str(uuid4()), + "Ods-From": "S44444", + } + + # Request body + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": args.nhs_number, + }, + } + ], + } + + # Make the POST request + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + response.raise_for_status() + + print(f"Status Code: {response.status_code}") + print(f"Response:\n{json.dumps(response.json(), indent=2)}") + + except requests.exceptions.RequestException as e: + errtext = f"Error: {e}\n" + if e.response is not None: + errtext += f"Status Code: {e.response.status_code}\n" + errtext += f"Response Body: {e.response.text}\n" + print(errtext) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/gateway-api/src/gateway_api/clinical_jwt/__init__.py b/gateway-api/src/gateway_api/clinical_jwt/__init__.py new file mode 100644 index 00000000..63d06bf2 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/__init__.py @@ -0,0 +1,6 @@ +from .device import Device +from .jwt import JWT +from .organization import Organization +from .practitioner import Practitioner + +__all__ = ["JWT", "Device", "Organization", "Practitioner"] diff --git a/gateway-api/src/gateway_api/clinical_jwt/device.py b/gateway-api/src/gateway_api/clinical_jwt/device.py new file mode 100644 index 00000000..2c733103 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/device.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, kw_only=True) +class Device: + system: str + value: str + model: str + version: str + + def to_dict(self) -> dict[str, Any]: + """ + Return the Device as a dictionary suitable for JWT payload. + """ + return { + "resourceType": "Device", + "identifier": [{"system": self.system, "value": self.value}], + "model": self.model, + "version": self.version, + } + + @property + def json(self) -> dict[str, Any]: + """ + Return the Device as a dictionary suitable for JWT payload. + Provided for backwards compatibility. + """ + return self.to_dict() + + def __str__(self) -> str: + import json + + return json.dumps(self.to_dict(), indent=2) diff --git a/gateway-api/src/gateway_api/clinical_jwt/jwt.py b/gateway-api/src/gateway_api/clinical_jwt/jwt.py new file mode 100644 index 00000000..f965d4df --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/jwt.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from time import time +from typing import Any + +import jwt as pyjwt + + +@dataclass(frozen=True, kw_only=True) +class JWT: + issuer: str + subject: str + audience: str + requesting_device: dict[str, Any] + requesting_organization: dict[str, Any] + requesting_practitioner: dict[str, Any] + + # Time fields + issued_at: int = field(default_factory=lambda: int(time())) + expiration: int = field(default_factory=lambda: int(time()) + 300) + + # These are here for future proofing but are not expected ever to be changed + algorithm: str | None = None + type: str = "JWT" + reason_for_request: str = "directcare" + requested_scope: str = "patient/*.read" + + @property + def issue_time(self) -> str: + return datetime.fromtimestamp(self.issued_at, tz=UTC).isoformat() + + @property + def exp_time(self) -> str: + return datetime.fromtimestamp(self.expiration, tz=UTC).isoformat() + + def encode(self) -> str: + return pyjwt.encode( + self.payload(), + key=None, + algorithm=self.algorithm, + headers={"typ": self.type}, + ) + + @staticmethod + def decode(token: str) -> "JWT": + token_dict = pyjwt.decode( + token, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + + return JWT( + issuer=token_dict["iss"], + subject=token_dict["sub"], + audience=token_dict["aud"], + expiration=token_dict["exp"], + issued_at=token_dict["iat"], + requesting_device=token_dict["requesting_device"], + requesting_organization=token_dict["requesting_organization"], + requesting_practitioner=token_dict["requesting_practitioner"], + ) + + def payload(self) -> dict[str, Any]: + return { + "iss": self.issuer, + "sub": self.subject, + "aud": self.audience, + "exp": self.expiration, + "iat": self.issued_at, + "requesting_device": self.requesting_device, + "requesting_organization": self.requesting_organization, + "requesting_practitioner": self.requesting_practitioner, + "reason_for_request": self.reason_for_request, + "requested_scope": self.requested_scope, + } + + def __str__(self) -> str: + return self.encode() diff --git a/gateway-api/src/gateway_api/clinical_jwt/organization.py b/gateway-api/src/gateway_api/clinical_jwt/organization.py new file mode 100644 index 00000000..18f69354 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/organization.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, kw_only=True) +class Organization: + ods_code: str + name: str + + def to_dict(self) -> dict[str, Any]: + """ + Return the Organization as a dictionary suitable for JWT payload. + """ + return { + "resourceType": "Organization", + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/ods-organization-code", + "value": self.ods_code, + } + ], + "name": self.name, + } + + @property + def json(self) -> dict[str, Any]: + """ + Return the Organization as a dictionary suitable for JWT payload. + Provided for backwards compatibility. + """ + return self.to_dict() + + def __str__(self) -> str: + import json + + return json.dumps(self.to_dict(), indent=2) diff --git a/gateway-api/src/gateway_api/clinical_jwt/practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py new file mode 100644 index 00000000..5e98e12b --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/practitioner.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(kw_only=True) +class Practitioner: + id: str + sds_userid: str + role_profile_id: str + userid_url: str + userid_value: str + family_name: str + given_name: str | None = None + prefix: str | None = None + + def to_dict(self) -> dict[str, Any]: + """ + Return the Practitioner as a dictionary suitable for JWT payload. + """ + user_id_system = "https://fhir.nhs.uk/Id/sds-user-id" + role_id_system = "https://fhir.nhs.uk/Id/sds-role-profile-id" + + name_dict: dict[str, Any] = {"family": self.family_name} + if self.given_name is not None: + name_dict["given"] = [self.given_name] + if self.prefix is not None: + name_dict["prefix"] = [self.prefix] + + return { + "resourceType": "Practitioner", + "id": self.id, + "identifier": [ + {"system": user_id_system, "value": self.sds_userid}, + {"system": role_id_system, "value": self.role_profile_id}, + {"system": self.userid_url, "value": self.userid_value}, + ], + "name": [name_dict], + } + + @property + def json(self) -> dict[str, Any]: + """ + Return the Practitioner as a dictionary suitable for JWT payload. + Provided for backwards compatibility. + """ + return self.to_dict() + + def __str__(self) -> str: + import json + + return json.dumps(self.to_dict(), indent=2) diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_device.py b/gateway-api/src/gateway_api/clinical_jwt/test_device.py new file mode 100644 index 00000000..6361dd46 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_device.py @@ -0,0 +1,67 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.device`. +""" + +from gateway_api.clinical_jwt import Device + + +def test_device_creation_with_all_required_fields() -> None: + """ + Test that a Device instance can be created with all required fields. + """ + device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + assert device.system == "https://consumersupplier.com/Id/device-identifier" + assert device.value == "CONS-APP-4" + assert device.model == "Consumer product name" + assert device.version == "5.3.0" + + +def test_device_json_property_returns_valid_json_structure() -> None: + """ + Test that the json property returns a valid dict structure for requesting_device. + """ + device = Device( + system="https://consumersupplier.com/Id/device-identifier", + value="CONS-APP-4", + model="Consumer product name", + version="5.3.0", + ) + + json_output = device.json + + expected_dict = { + "resourceType": "Device", + "identifier": [ + { + "system": "https://consumersupplier.com/Id/device-identifier", + "value": "CONS-APP-4", + } + ], + "model": "Consumer product name", + "version": "5.3.0", + } + + assert json_output == expected_dict + + +def test_device_str_returns_json() -> None: + """ + Test that __str__ returns a JSON string representation of the dictionary. + """ + device = Device( + system="https://test.com/device", + value="TEST-001", + model="Test Model", + version="1.0.0", + ) + + # __str__ now returns a JSON string, while .json returns a dict + import json + + assert json.loads(str(device)) == device.json diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py new file mode 100644 index 00000000..a8f0f038 --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_jwt.py @@ -0,0 +1,174 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.jwt`. +""" + +from unittest.mock import Mock, patch + +import jwt as pyjwt +import pytest + +from gateway_api.clinical_jwt import JWT + + +def test_jwt_creation_with_required_fields() -> None: + """ + Test that a JWT instance can be created with all required fields. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + ) + + assert token.issuer == "https://example.com" + assert token.subject == "user-123" + assert token.audience == "https://provider.example.com" + assert token.requesting_device == {"device": "info"} + assert token.requesting_organization == {"org": "info"} + assert token.requesting_practitioner == {"practitioner": "info"} + assert token.algorithm is None + assert token.type == "JWT" + assert token.reason_for_request == "directcare" + assert token.requested_scope == "patient/*.read" + + +@patch("gateway_api.clinical_jwt.jwt.time") +def test_jwt_default_issued_at_and_expiration(mock_time: Mock) -> None: + """ + Test that issued_at and expiration have correct default values. + """ + mock_time.return_value = 1000.0 + + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + ) + + assert token.issued_at == 1000 + assert token.expiration == 1300 # issued_at + 300 + + +def test_jwt_issue_time_property() -> None: + """ + Test that issue_time property returns ISO formatted timestamp. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + issued_at=1609459200, # 2021-01-01 00:00:00 UTC + ) + + assert token.issue_time == "2021-01-01T00:00:00+00:00" + + +def test_jwt_exp_time_property() -> None: + """ + Test that exp_time property returns ISO formatted timestamp. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + expiration=1609459500, # 2021-01-01 00:05:00 UTC + ) + + assert token.exp_time == "2021-01-01T00:05:00+00:00" + + +def test_jwt_payload_contains_all_required_fields() -> None: + """ + Test that payload() returns a dictionary with all required JWT fields. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + issued_at=1000, + expiration=1300, + ) + + payload = token.payload() + + expected = { + "iss": token.issuer, + "sub": token.subject, + "aud": token.audience, + "exp": token.expiration, + "iat": token.issued_at, + "requesting_device": token.requesting_device, + "requesting_organization": token.requesting_organization, + "requesting_practitioner": token.requesting_practitioner, + "reason_for_request": token.reason_for_request, + "requested_scope": token.requested_scope, + } + + assert payload == expected + + +def test_jwt_encode_returns_string() -> None: + """ + Test that encode() returns a valid JWT token string with correct structure. + """ + token = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + issued_at=1000, + expiration=1300, + ) + + encoded = token.encode() + + # Use PyJWT to decode and verify the token structure + try: + pyjwt.decode( + encoded, + options={"verify_signature": False}, # NOSONAR S5659 (not signed) + ) + except pyjwt.DecodeError as err: + pytest.fail(f"Failed to decode JWT: {err}") + except Exception as err: + pytest.fail(f"Unexpected error during JWT decoding: {err}") + + +def test_jwt_decode_reconstructs_token() -> None: + """ + Test that decode() can reconstruct a JWT from an encoded token string. + """ + original = JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + issued_at=1000, + expiration=1300, + ) + + encoded = original.encode() + decoded = JWT.decode(encoded) + + assert decoded == original, ( + f"The decoded token, {decoded}, does not match the original, {original}" + ) diff --git a/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py new file mode 100644 index 00000000..17d13f3e --- /dev/null +++ b/gateway-api/src/gateway_api/clinical_jwt/test_practitioner.py @@ -0,0 +1,109 @@ +""" +Unit tests for :mod:`gateway_api.clinical_jwt.practitioner`. +""" + +from gateway_api.clinical_jwt import Practitioner + + +def test_practitioner_creation_with_all_fields() -> None: + """ + Test that a Practitioner instance can be created with all fields. + """ + practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + assert practitioner.id == "10019" + assert practitioner.sds_userid == "111222333444" + assert practitioner.role_profile_id == "444555666777" + assert practitioner.userid_url == "https://consumersupplier.com/Id/user-guid" + assert practitioner.userid_value == "98ed4f78-814d-4266-8d5b-cde742f3093c" + assert practitioner.family_name == "Doe" + assert practitioner.given_name == "John" + assert practitioner.prefix == "Mr" + + +def test_practitioner_json_property_returns_valid_structure() -> None: + """ + Test that the json property returns a valid dictionary structure for + requesting_practitioner. + """ + practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + json_output = practitioner.json + + # Verify it's a dictionary + assert isinstance(json_output, dict) + + # Verify it contains the expected fields + assert json_output["resourceType"] == "Practitioner" + assert json_output["id"] == practitioner.id + assert "identifier" in json_output + assert len(json_output["identifier"]) == 3 + assert json_output["identifier"][0]["value"] == practitioner.sds_userid + assert json_output["identifier"][1]["value"] == practitioner.role_profile_id + assert json_output["identifier"][2]["system"] == practitioner.userid_url + assert json_output["identifier"][2]["value"] == practitioner.userid_value + assert json_output["name"][0]["family"] == practitioner.family_name + assert json_output["name"][0]["given"][0] == practitioner.given_name + assert json_output["name"][0]["prefix"][0] == practitioner.prefix + + +def test_practitioner_str_returns_json() -> None: + """ + Test that __str__ returns a JSON string representation of the dictionary. + """ + practitioner = Practitioner( + id="10026", + sds_userid="888999000111", + role_profile_id="111222333444", + userid_url="https://test.com/user", + userid_value="test-guid-7", + family_name="Taylor", + ) + + # __str__ now returns a JSON string, while .json returns a dict + import json + + assert json.loads(str(practitioner)) == practitioner.json + + +def test_practitioner_identifier_systems() -> None: + """ + Test that the correct identifier systems are used in the JSON output. + """ + practitioner = Practitioner( + id="10027", + sds_userid="999000111222", + role_profile_id="222333444555", + userid_url="https://test.com/user", + userid_value="test-guid-8", + family_name="Anderson", + ) + + json_output = practitioner.json + + # Verify the correct system URLs are used + assert ( + json_output["identifier"][0]["system"] == "https://fhir.nhs.uk/Id/sds-user-id" + ) + assert ( + json_output["identifier"][1]["system"] + == "https://fhir.nhs.uk/Id/sds-role-profile-id" + ) diff --git a/gateway-api/src/gateway_api/common/common.py b/gateway-api/src/gateway_api/common/common.py index 3891b8f3..f9452f1a 100644 --- a/gateway-api/src/gateway_api/common/common.py +++ b/gateway-api/src/gateway_api/common/common.py @@ -4,6 +4,7 @@ import re from dataclasses import dataclass +from http import HTTPStatus # This project uses JSON request/response bodies as strings in the controller layer. # The alias is used to make intent clearer in function signatures. @@ -61,3 +62,8 @@ def validate_nhs_number(value: str | int) -> bool: return False # invalid NHS number return check == provided_check_digit + + +def get_http_text(status_code: int) -> str: + status = HTTPStatus(status_code) + return status.phrase diff --git a/gateway-api/src/gateway_api/conftest.py b/gateway-api/src/gateway_api/conftest.py index 65e3c779..8ef4c2d9 100644 --- a/gateway-api/src/gateway_api/conftest.py +++ b/gateway-api/src/gateway_api/conftest.py @@ -6,7 +6,8 @@ import pytest import requests -from fhir import Bundle, OperationOutcome, Parameters, Patient +from fhir import Bundle, OperationOutcome, Patient +from fhir.parameters import Parameters from flask import Request from requests.structures import CaseInsensitiveDict from werkzeug.test import EnvironBuilder diff --git a/gateway-api/src/gateway_api/controller.py b/gateway-api/src/gateway_api/controller.py index 1cda4a94..631e2e02 100644 --- a/gateway-api/src/gateway_api/controller.py +++ b/gateway-api/src/gateway_api/controller.py @@ -2,6 +2,7 @@ Controller layer for orchestrating calls to external services """ +from gateway_api.clinical_jwt import JWT, Device, Organization, Practitioner from gateway_api.common.common import FlaskResponse from gateway_api.common.error import ( NoAsidFoundError, @@ -57,11 +58,14 @@ def run(self, request: GetStructuredRecordRequest) -> FlaskResponse: request.ods_from.strip(), provider_ods ) + token = self.get_jwt_for_provider(provider_endpoint, request.ods_from.strip()) + # Call GP provider with correct parameters self.gp_provider_client = GpProviderClient( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=token, ) response = self.gp_provider_client.access_structured_record( @@ -84,6 +88,58 @@ def get_auth_token(self) -> str: """ return "AUTH_TOKEN123" + def get_jwt_for_provider(self, provider_endpoint: str, consumer_ods: str) -> JWT: + # For requesting device details, see: + # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_device-claim + # For requesting practitioner details, see: + # https://webarchive.nationalarchives.gov.uk/ukgwa/20250307092533/https://developer.nhs.uk/apis/gpconnect/integration_cross_organisation_audit_and_provenance.html#requesting_practitioner-claim + + # TODO: Get requesting device details from consumer, somehow? + # requesting_device = Device( + # system="https://consumersupplier.com/Id/device-identifier", + # value="CONS-APP-4", + # model="Consumer product name", + # version="5.3.0", + # ) + + requesting_device = Device( + system="https://orange.testlab.nhs.uk/gpconnect-demonstrator/Id/local-system-instance-id", + value="gpcdemonstrator-1-orange", + model="GP Connect Demonstrator", + version="1.5.0", + ) + + # TODO: Get practitioner details from consumer, somehow? + requesting_practitioner = Practitioner( + id="10019", + sds_userid="111222333444", + role_profile_id="444555666777", + userid_url="https://consumersupplier.com/Id/user-guid", + userid_value="98ed4f78-814d-4266-8d5b-cde742f3093c", + family_name="Doe", + given_name="John", + prefix="Mr", + ) + + # TODO: Get consumer organization details properly + requesting_organization = Organization( + ods_code=consumer_ods, name="Consumer organisation name" + ) + + # TODO: Get consumer URL for issuer. Use CDG API URL for now. + issuer = "https://clinical-data-gateway-api.sandbox.nhs.uk" + audience = provider_endpoint + + token = JWT( + issuer=issuer, + subject=requesting_practitioner.id, + audience=audience, + requesting_device=requesting_device.to_dict(), + requesting_organization=requesting_organization.to_dict(), + requesting_practitioner=requesting_practitioner.to_dict(), + ) + return token + def _get_pds_details(self, auth_token: str, nhs_number: str) -> str: """ Call PDS to find the provider ODS code (GP ODS code) for a patient. diff --git a/gateway-api/src/gateway_api/get_structured_record/request.py b/gateway-api/src/gateway_api/get_structured_record/request.py index 94e8a23d..7e723e7c 100644 --- a/gateway-api/src/gateway_api/get_structured_record/request.py +++ b/gateway-api/src/gateway_api/get_structured_record/request.py @@ -9,15 +9,15 @@ from gateway_api.common.common import FlaskResponse from gateway_api.common.error import InvalidRequestJSONError, MissingOrEmptyHeaderError -if TYPE_CHECKING: - from fhir.bundle import Bundle - # Access record structured interaction ID from # https://developer.nhs.uk/apis/gpconnect/accessrecord_structured_development.html#spine-interactions ACCESS_RECORD_STRUCTURED_INTERACTION_ID = ( "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ) +if TYPE_CHECKING: + from fhir.bundle import Bundle + class GetStructuredRecordRequest: INTERACTION_ID: ClassVar[str] = ACCESS_RECORD_STRUCTURED_INTERACTION_ID diff --git a/gateway-api/src/gateway_api/pds/client.py b/gateway-api/src/gateway_api/pds/client.py index 0bee89fa..87ec279b 100644 --- a/gateway-api/src/gateway_api/pds/client.py +++ b/gateway-api/src/gateway_api/pds/client.py @@ -21,7 +21,7 @@ import os import uuid from collections.abc import Callable -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from typing import cast import requests @@ -230,7 +230,7 @@ def find_current_gp( today: date | None = None, ) -> GeneralPractitioner | None: if today is None: - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() if self.ignore_dates: if len(general_practitioners) > 0: @@ -252,7 +252,7 @@ def find_current_name_record( self, names: list[HumanName], today: date | None = None ) -> HumanName | None: if today is None: - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() if self.ignore_dates: if len(names) > 0: diff --git a/gateway-api/src/gateway_api/provider/client.py b/gateway-api/src/gateway_api/provider/client.py index 36be04e1..fca1c465 100644 --- a/gateway-api/src/gateway_api/provider/client.py +++ b/gateway-api/src/gateway_api/provider/client.py @@ -27,6 +27,8 @@ from requests import HTTPError, Response +from gateway_api.clinical_jwt import JWT +from gateway_api.common.common import get_http_text from gateway_api.common.error import ProviderRequestFailedError from gateway_api.get_structured_record import ACCESS_RECORD_STRUCTURED_INTERACTION_ID @@ -42,9 +44,8 @@ provider_stub = GpProviderStub() post = provider_stub.post # type: ignore -ARS_FHIR_BASE = "FHIR/STU3" -FHIR_RESOURCE = "patient" -ARS_FHIR_OPERATION = "$gpc.getstructuredrecord" +# Default endpoint path for access record structured interaction (standard GP Connect) +ARS_ENDPOINT_PATH = "Patient/$gpc.getstructuredrecord" TIMEOUT: int | None = None # None used for quicker dev, adjust as needed @@ -56,9 +57,12 @@ class GpProviderClient: including fetching structured patient records. Attributes: - provider_endpoint (str): The FHIR API endpoint for the provider. + provider_endpoint (str): The base URL for the provider (from SDS). provider_asid (str): The ASID for the provider. consumer_asid (str): The ASID for the consumer. + token (JWT): JWT object for authentication with the provider API. + endpoint_path (str): The endpoint path for the operation + (default: "FHIR/STU3/patient/$gpc.getstructuredrecord"). Methods: access_structured_record(trace_id: str, body: str) -> Response: @@ -70,22 +74,28 @@ def __init__( provider_endpoint: str, provider_asid: str, consumer_asid: str, + token: JWT, + endpoint_path: str = ARS_ENDPOINT_PATH, ) -> None: self.provider_endpoint = provider_endpoint self.provider_asid = provider_asid self.consumer_asid = consumer_asid + self.token = token + self.endpoint_path = endpoint_path def _build_headers(self, trace_id: str) -> dict[str, str]: """ Build the headers required for the GPProvider FHIR API request. """ + # TODO: Post-steel-thread, probably check whether JWT is valid/not expired return { - "Content-Type": "application/fhir+json", - "Accept": "application/fhir+json", + "Content-Type": "application/fhir+json;charset=utf-8", + "Accept": "application/fhir+json;charset=utf-8", "Ssp-InteractionID": ACCESS_RECORD_STRUCTURED_INTERACTION_ID, "Ssp-To": self.provider_asid, "Ssp-From": self.consumer_asid, "Ssp-TraceID": trace_id, + "Authorization": f"Bearer {self.token}", } def access_structured_record( @@ -99,8 +109,7 @@ def access_structured_record( headers = self._build_headers(trace_id) - endpoint_path = "/".join([ARS_FHIR_BASE, FHIR_RESOURCE, ARS_FHIR_OPERATION]) - url = urljoin(self.provider_endpoint, endpoint_path) + url = urljoin(self.provider_endpoint, self.endpoint_path) response = post( url, @@ -112,6 +121,15 @@ def access_structured_record( try: response.raise_for_status() except HTTPError as err: - raise ProviderRequestFailedError(error_reason=err.response.reason) from err + errstr = "GPProvider FHIR API request failed:\n" + errstr += f"{response.status_code}: " + errstr += f"{get_http_text(response.status_code)}: {response.reason}\n" + errstr += response.text + errstr += "\nHeaders were:\n" + for header, value in headers.items(): + errstr += f"{header}: {value}\n" + errstr += "\nBody payload was:\n" + errstr += body + raise ProviderRequestFailedError(error_reason=errstr) from err return response diff --git a/gateway-api/src/gateway_api/provider/test_client.py b/gateway-api/src/gateway_api/provider/test_client.py index 98b4a118..21f50e4b 100644 --- a/gateway-api/src/gateway_api/provider/test_client.py +++ b/gateway-api/src/gateway_api/provider/test_client.py @@ -15,6 +15,7 @@ from requests.structures import CaseInsensitiveDict from stubs.provider.stub import GpProviderStub +from gateway_api.clinical_jwt import JWT from gateway_api.common.error import ProviderRequestFailedError from gateway_api.provider import GpProviderClient, client @@ -58,9 +59,22 @@ def _fake_post( return capture +@pytest.fixture +def dummy_jwt() -> JWT: + return JWT( + issuer="https://example.com", + subject="user-123", + audience="https://provider.example.com", + requesting_device={"device": "info"}, + requesting_organization={"org": "info"}, + requesting_practitioner={"practitioner": "info"}, + ) + + def test_valid_gpprovider_access_structured_record_makes_request_correct_url_post_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method constructs the correct URL @@ -78,6 +92,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) result = client.access_structured_record( @@ -96,6 +111,7 @@ def test_valid_gpprovider_access_structured_record_makes_request_correct_url_pos def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method includes the correct headers @@ -114,6 +130,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) expected_headers = { "Content-Type": "application/fhir+json", @@ -124,6 +141,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 "Ssp-InteractionID": ( "urn:nhs:names:services:gpconnect:fhir:operation:gpc.getstructuredrecord-1" ), + "Authorization": f"Bearer {dummy_jwt}", } result = client.access_structured_record( @@ -139,6 +157,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_headers_post_200 def test_valid_gpprovider_access_structured_record_with_correct_body_200( mock_request_post: dict[str, Any], valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method includes the correct body @@ -158,6 +177,7 @@ def test_valid_gpprovider_access_structured_record_with_correct_body_200( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) result = client.access_structured_record(trace_id, request_body) @@ -172,6 +192,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) stub: GpProviderStub, valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method returns the same response @@ -189,6 +210,7 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) expected_response = stub.access_record_structured( @@ -205,6 +227,8 @@ def test_valid_gpprovider_access_structured_record_returns_stub_response_200( def test_access_structured_record_raises_external_service_error( mock_request_post: dict[str, Any], # NOQA ARG001 (Mock not called directly) + valid_simple_request_payload: Parameters, + dummy_jwt: JWT, ) -> None: """ Test that the `access_structured_record` method raises an `SdsRequestFailed` @@ -219,10 +243,45 @@ def test_access_structured_record_raises_external_service_error( provider_endpoint=provider_endpoint, provider_asid=provider_asid, consumer_asid=consumer_asid, + token=dummy_jwt, ) with pytest.raises( ProviderRequestFailedError, match="Provider request failed: Bad Request", ): - client.access_structured_record(trace_id, "body") + client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) + + +def test_gpprovider_client_includes_authorization_header_with_bearer_token( + mock_request_post: dict[str, Any], + valid_simple_request_payload: Parameters, + dummy_jwt: JWT, +) -> None: + """ + Test that the GpProviderClient includes an Authorization header with the + Bearer token. + """ + provider_asid = "200000001154" + consumer_asid = "200000001152" + provider_endpoint = "https://test.com" + trace_id = "test-trace-id" + + client = GpProviderClient( + provider_endpoint=provider_endpoint, + provider_asid=provider_asid, + consumer_asid=consumer_asid, + token=dummy_jwt, + ) + + result = client.access_structured_record( + trace_id, json.dumps(valid_simple_request_payload) + ) + + captured_headers = mock_request_post["headers"] + + assert "Authorization" in captured_headers + assert captured_headers["Authorization"] == f"Bearer {dummy_jwt}" + assert result.status_code == 200 diff --git a/gateway-api/src/gateway_api/test_app.py b/gateway-api/src/gateway_api/test_app.py index bd282cb4..de7982f9 100644 --- a/gateway-api/src/gateway_api/test_app.py +++ b/gateway-api/src/gateway_api/test_app.py @@ -21,7 +21,7 @@ @pytest.fixture -def client() -> Generator[FlaskClient[Flask], None, None]: +def client() -> Generator[FlaskClient[Flask]]: app.config["TESTING"] = True with app.test_client() as client: yield client diff --git a/gateway-api/src/gateway_api/test_controller.py b/gateway-api/src/gateway_api/test_controller.py index fc783205..a55abbc2 100644 --- a/gateway-api/src/gateway_api/test_controller.py +++ b/gateway-api/src/gateway_api/test_controller.py @@ -280,3 +280,73 @@ def mock_happy_path_get_structured_record_request( body=valid_simple_request_payload, ) return happy_path_request + + +def test_controller_creates_jwt_token_with_correct_claims( + mocker: MockerFixture, + valid_simple_request_payload: Parameters, + valid_simple_response_payload: Bundle, +) -> None: + """ + Test that the controller creates a JWT token with the correct claims. + """ + nhs_number = "9000000009" + provider_ods = "PROVIDER" + consumer_ods = "CONSUMER" + provider_endpoint = "https://provider.example/ep" + + # Mock PDS to return provider ODS code + pds_search_result = PdsSearchResults( + given_names="Jane", + family_name="Smith", + nhs_number=nhs_number, + gp_ods_code=provider_ods, + ) + mocker.patch( + "gateway_api.pds.PdsClient.search_patient_by_nhs_number", + return_value=pds_search_result, + ) + + # Mock SDS to return provider and consumer details + provider_sds_results = SdsSearchResults( + asid="asid_PROV", endpoint=provider_endpoint + ) + consumer_sds_results = SdsSearchResults(asid="asid_CONS", endpoint=None) + mocker.patch( + "gateway_api.sds.SdsClient.get_org_details", + side_effect=[provider_sds_results, consumer_sds_results], + ) + + # Mock GpProviderClient to capture initialization arguments + mock_gp_provider = mocker.patch("gateway_api.controller.GpProviderClient") + + # Mock the access_structured_record method to return a response + provider_response = FakeResponse( + status_code=200, + headers={"Content-Type": "application/fhir+json"}, + _json=valid_simple_response_payload, + ) + mock_gp_provider.return_value.access_structured_record.return_value = ( + provider_response + ) + + # Create request and run controller + request = create_mock_request( + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, + body=valid_simple_request_payload, + ) + + controller = Controller() + _ = controller.run(GetStructuredRecordRequest(request)) + + # Verify that GpProviderClient was called and extract the JWT token + mock_gp_provider.assert_called_once() + jwt_token = mock_gp_provider.call_args.kwargs["token"] + + # Verify the standard JWT claims + assert jwt_token.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" + assert jwt_token.subject == "10019" + assert jwt_token.audience == provider_endpoint + + # Verify the requesting organization matches the consumer ODS + assert jwt_token.requesting_organization["identifier"][0]["value"] == consumer_ods diff --git a/gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json b/gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json new file mode 100644 index 00000000..1660e792 --- /dev/null +++ b/gateway-api/stubs/stubs/data/patients/orange_box_trigger_9690937278.json @@ -0,0 +1,34 @@ +{ + "resourceType": "Patient", + "id": "9690937278", + "meta": { + "versionId": "1", + "lastUpdated": "2020-01-01T00:00:00Z" + }, + "identifier": [ + { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9690937278" + } + ], + "name": [ + { + "use": "official", + "family": "Samual", + "given": ["Lucien"], + "period": {"start": "1900-01-01", "end": "9999-12-31"} + } + ], + "gender": "male", + "birthDate": "1938-12-11", + "generalPractitioner": [ + { + "id": "1", + "type": "Organization", + "identifier": { + "value": "S55555", + "period": {"start": "2020-01-01", "end": "9999-12-31"} + } + } + ] +} diff --git a/gateway-api/stubs/stubs/data/patients/patients.py b/gateway-api/stubs/stubs/data/patients/patients.py index e21ce2d1..1b6a5a0d 100644 --- a/gateway-api/stubs/stubs/data/patients/patients.py +++ b/gateway-api/stubs/stubs/data/patients/patients.py @@ -26,3 +26,4 @@ def load_patient(filename: str) -> dict[str, Any]: "blank_endpoint_sds_result_9000000013.json" ) ALICE_JONES_9999999999 = load_patient("alice_jones_9999999999.json") + ORANGE_BOX_TRIGGER_9690937278 = load_patient("orange_box_trigger_9690937278.json") diff --git a/gateway-api/stubs/stubs/pds/stub.py b/gateway-api/stubs/stubs/pds/stub.py index 23e36d7b..c7c29014 100644 --- a/gateway-api/stubs/stubs/pds/stub.py +++ b/gateway-api/stubs/stubs/pds/stub.py @@ -6,7 +6,7 @@ import re import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from requests import Response @@ -52,6 +52,7 @@ def __init__(self, strict_headers: bool = True) -> None: ("9000000011", Patients.BLANK_ASID_SDS_RESULT_9000000011), ("9000000012", Patients.INDUCE_PROVIDER_ERROR_9000000012), ("9000000013", Patients.BLANK_ENDPOINT_SDS_RESULT_9000000013), + ("9690937278", Patients.ORANGE_BOX_TRIGGER_9690937278), ] for nhs_number, patient in test_patients: self.upsert_patient( @@ -239,10 +240,7 @@ def _now_fhir_instant() -> str: :return: Timestamp string in the format ``YYYY-MM-DDTHH:MM:SSZ``. """ return ( - datetime.now(timezone.utc) - .replace(microsecond=0) - .isoformat() - .replace("+00:00", "Z") + datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") ) @staticmethod diff --git a/gateway-api/stubs/stubs/provider/stub.py b/gateway-api/stubs/stubs/provider/stub.py index 0e157505..a1f8d58d 100644 --- a/gateway-api/stubs/stubs/provider/stub.py +++ b/gateway-api/stubs/stubs/provider/stub.py @@ -70,7 +70,7 @@ def access_record_structured( try: nhs_number = json.loads(body)["parameter"][0]["valueIdentifier"]["value"] - except (json.JSONDecodeError, KeyError, IndexError): + except json.JSONDecodeError, KeyError, IndexError: return self._create_response( status_code=400, json_data={ diff --git a/gateway-api/stubs/stubs/sds/stub.py b/gateway-api/stubs/stubs/sds/stub.py index afbd6e3b..fdd04f5c 100644 --- a/gateway-api/stubs/stubs/sds/stub.py +++ b/gateway-api/stubs/stubs/sds/stub.py @@ -403,6 +403,20 @@ def _seed_default_devices(self) -> None: "asid": "ASIDforGPWithoutEndpoint", "display": "GP with no provider endpoint - testing error handling", }, + { + "org_ods": "S44444", + "party_key": "S44444-0000809", + "device_id": "B2B2E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "200000000359", + "display": "Dummy ODS/ASID for Orange Box", + }, + { + "org_ods": "S55555", + "party_key": "S55555-0000809", + "device_id": "B3B3E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "918999198738", + "display": "ODS/ASID triggering Orange Box", + }, ] # Iterate through test data and create devices @@ -445,6 +459,13 @@ def _seed_default_endpoints(self) -> None: "asid": "asid_A12345", "address": "https://a12345.example.com/fhir", }, + { + "org_ods": "S55555", + "party_key": "S55555-0000809", + "endpoint_id": "E3E3E921-92CA-4A88-A550-2DBB36F703AF", + "asid": "918999198738", + "address": "https://orange.testlab.nhs.uk/B82617/STU3/1/gpconnect/structured/fhir/", + }, ] # Iterate through test data and create endpoints diff --git a/gateway-api/tests/contract/conftest.py b/gateway-api/tests/contract/conftest.py index 49df8670..d451f3e5 100644 --- a/gateway-api/tests/contract/conftest.py +++ b/gateway-api/tests/contract/conftest.py @@ -76,7 +76,7 @@ def do_PUT(self) -> None: @pytest.fixture(scope="module") -def mtls_proxy(base_url: str) -> Generator[str, None, None]: +def mtls_proxy(base_url: str) -> Generator[str]: """ Spins up a local HTTP server in a separate thread. Returns the URL of this local proxy. diff --git a/gateway-api/tests/integration/test_sds_search.py b/gateway-api/tests/integration/test_sds_search.py index 04b8fc84..b241f432 100644 --- a/gateway-api/tests/integration/test_sds_search.py +++ b/gateway-api/tests/integration/test_sds_search.py @@ -2,51 +2,100 @@ from __future__ import annotations -from gateway_api.sds import SdsClient +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tests.conftest import Client class TestSdsIntegration: """Integration tests for SDS search operations.""" - def test_get_device_by_ods_code_returns_valid_asid(self) -> None: + def test_get_device_by_ods_code_returns_valid_asid(self, client: Client) -> None: """ Test that querying by ODS code returns a valid ASID. - - :param sds_client: SDS client fixture configured with stub. """ - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="PROVIDER") + # Create a request payload with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } + + # Make request to the application endpoint + response = client.send_to_get_structured_record_endpoint(json.dumps(payload)) - assert result is not None - assert result.asid == "asid_PROV" - assert result.endpoint == "https://provider.example.com/fhir" + # Verify successful response indicates SDS lookup worked + assert response.status_code == 200 + # Verify we got a FHIR response (which means the full flow including SDS worked) + response_data = response.json() + assert response_data.get("resourceType") == "Bundle" - def test_consumer_organization_lookup(self) -> None: + def test_consumer_organization_lookup(self, client: Client) -> None: """ Test that CONSUMER organization can be looked up successfully. - - :param sds_client: SDS client fixture configured with stub. """ - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="CONSUMER") + # Create a request with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } + + # Use A12345 as the consumer ODS (Ods-from header) + response = client.send_to_get_structured_record_endpoint( + json.dumps(payload), + headers={"Ods-from": "A12345"}, # Consumer ODS code + ) - assert result is not None - assert result.asid == "asid_CONS" - assert result.endpoint == "https://consumer.example.com/fhir" + # Verify successful response indicates both consumer and provider + # SDS lookups worked + assert response.status_code == 200 + response_data = response.json() + assert response_data.get("resourceType") == "Bundle" - def test_result_contains_both_asid_and_endpoint_when_available(self) -> None: + def test_result_contains_both_asid_and_endpoint_when_available( + self, client: Client + ) -> None: """ Test that results contain both ASID and endpoint when both are available. - - :param sds_client: SDS client fixture configured with stub. """ + # Create a request with a known patient + payload = { + "resourceType": "Parameters", + "parameter": [ + { + "name": "patientNHSNumber", + "valueIdentifier": { + "system": "https://fhir.nhs.uk/Id/nhs-number", + "value": "9999999999", # Alice Jones with A12345 provider + }, + }, + ], + } - sds_client = SdsClient() - result = sds_client.get_org_details(ods_code="PROVIDER") + # Make request to the application endpoint + response = client.send_to_get_structured_record_endpoint(json.dumps(payload)) - assert result is not None - # Verify both fields are present and not None - assert hasattr(result, "asid") - assert hasattr(result, "endpoint") - assert result.asid is not None - assert result.endpoint is not None + # Verify successful response (200) means both ASID and endpoint were retrieved + # If either were missing, the application would fail with an error + assert response.status_code == 200 + response_data = response.json() + # Verify we got a valid FHIR Bundle (indicating full flow including SDS worked) + assert response_data.get("resourceType") == "Bundle" + assert "entry" in response_data or response_data.get("total") is not None