|
1 | 1 | """Unit tests for :mod:`gateway_api.controller`.""" |
2 | 2 |
|
| 3 | +import json |
| 4 | +from copy import deepcopy |
3 | 5 | from typing import Any |
4 | 6 |
|
5 | 7 | import pytest |
@@ -287,67 +289,207 @@ def mock_happy_path_get_structured_record_request( |
287 | 289 | return happy_path_request |
288 | 290 |
|
289 | 291 |
|
290 | | -def test_controller_creates_jwt_token_with_correct_claims( |
| 292 | +# TODO: Look at this more carefully. Does it do things done differently above? |
| 293 | +def _setup_pds_sds_mocks( |
291 | 294 | mocker: MockerFixture, |
292 | | - valid_simple_request_payload: dict[str, Any], |
293 | | - valid_simple_response_payload: dict[str, Any], |
| 295 | + nhs_number: str, |
| 296 | + provider_ods: str, |
| 297 | + provider_endpoint: str, |
294 | 298 | ) -> None: |
295 | | - """ |
296 | | - Test that the controller creates a JWT token with the correct claims. |
297 | | - """ |
298 | | - nhs_number = "9000000009" |
299 | | - provider_ods = "PROVIDER" |
300 | | - consumer_ods = "CONSUMER" |
301 | | - provider_endpoint = "https://provider.example/ep" |
302 | | - |
303 | | - # Mock PDS to return provider ODS code |
304 | 299 | mocker.patch( |
305 | 300 | "gateway_api.pds.PdsClient.search_patient_by_nhs_number", |
306 | 301 | return_value=_create_patient(nhs_number, provider_ods), |
307 | 302 | ) |
308 | | - |
309 | | - # Mock SDS to return provider and consumer details |
310 | | - provider_sds_results = SdsSearchResults( |
311 | | - asid="asid_PROV", endpoint=provider_endpoint |
312 | | - ) |
313 | | - consumer_sds_results = SdsSearchResults(asid="asid_CONS", endpoint=None) |
314 | 303 | mocker.patch( |
315 | 304 | "gateway_api.sds.SdsClient.get_org_details", |
316 | | - side_effect=[provider_sds_results, consumer_sds_results], |
| 305 | + side_effect=[ |
| 306 | + SdsSearchResults(asid="asid_PROV", endpoint=provider_endpoint), |
| 307 | + SdsSearchResults(asid="asid_CONS", endpoint=None), |
| 308 | + ], |
317 | 309 | ) |
318 | 310 |
|
319 | | - # Mock GpProviderClient to capture initialization arguments |
320 | | - mock_gp_provider = mocker.patch("gateway_api.controller.GpProviderClient") |
321 | 311 |
|
322 | | - # Mock the access_structured_record method to return a response |
323 | | - provider_response = FakeResponse( |
| 312 | +def _setup_provider_mock( |
| 313 | + mocker: MockerFixture, |
| 314 | + valid_simple_response_payload: dict[str, Any], |
| 315 | +) -> Any: |
| 316 | + mock_gp_provider = mocker.patch("gateway_api.controller.GpProviderClient") |
| 317 | + mock_gp_provider.return_value.access_structured_record.return_value = FakeResponse( |
324 | 318 | status_code=200, |
325 | 319 | headers={"Content-Type": "application/fhir+json"}, |
326 | 320 | _json=valid_simple_response_payload, |
327 | 321 | ) |
328 | | - mock_gp_provider.return_value.access_structured_record.return_value = ( |
329 | | - provider_response |
330 | | - ) |
| 322 | + return mock_gp_provider |
| 323 | + |
| 324 | + |
| 325 | +def test_controller_creates_jwt_token_with_correct_claims( |
| 326 | + mocker: MockerFixture, |
| 327 | + valid_simple_request_payload: dict[str, Any], |
| 328 | + valid_simple_response_payload: dict[str, Any], |
| 329 | +) -> None: |
| 330 | + """ |
| 331 | + Test that the controller creates a JWT token with the correct claims, |
| 332 | + taking issuer, requestingDevice, and requestingPractitioner from the |
| 333 | + identity section of the request body. |
| 334 | + """ |
| 335 | + nhs_number = "9000000009" |
| 336 | + provider_ods = "PROVIDER" |
| 337 | + consumer_ods = "CONSUMER" |
| 338 | + provider_endpoint = "https://provider.example/ep" |
| 339 | + |
| 340 | + _setup_pds_sds_mocks(mocker, nhs_number, provider_ods, provider_endpoint) |
| 341 | + mock_gp_provider = _setup_provider_mock(mocker, valid_simple_response_payload) |
331 | 342 |
|
332 | | - # Create request and run controller |
333 | 343 | request = create_mock_request( |
334 | 344 | headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, |
335 | 345 | body=valid_simple_request_payload, |
336 | 346 | ) |
337 | 347 |
|
338 | | - get_structured_record_request = GetStructuredRecordRequest(request) |
339 | | - |
340 | 348 | controller = Controller() |
341 | | - controller.run(get_structured_record_request) |
| 349 | + controller.run(GetStructuredRecordRequest(request)) |
342 | 350 |
|
343 | | - # Verify that GpProviderClient was called and extract the JWT token |
344 | 351 | mock_gp_provider.assert_called_once() |
345 | 352 | jwt_token = mock_gp_provider.call_args.kwargs["token"] |
346 | 353 |
|
347 | | - # Verify the standard JWT claims |
| 354 | + # Issuer, subject and audience come from the identity section and provider endpoint |
348 | 355 | assert jwt_token.issuer == "https://clinical-data-gateway-api.sandbox.nhs.uk" |
349 | 356 | assert jwt_token.subject == "10019" |
350 | 357 | assert jwt_token.audience == provider_endpoint |
351 | 358 |
|
352 | | - # Verify the requesting organization matches the consumer ODS |
| 359 | + # Requesting organisation is built from the consumer ODS code |
353 | 360 | assert jwt_token.requesting_organization["identifier"][0]["value"] == consumer_ods |
| 361 | + |
| 362 | + |
| 363 | +def test_controller_strips_identity_from_forwarded_body( |
| 364 | + mocker: MockerFixture, |
| 365 | + valid_simple_request_payload: dict[str, Any], |
| 366 | + valid_simple_response_payload: dict[str, Any], |
| 367 | +) -> None: |
| 368 | + """ |
| 369 | + Test that the identity parameter is removed from the body sent to the provider. |
| 370 | + """ |
| 371 | + nhs_number = "9000000009" |
| 372 | + provider_ods = "PROVIDER" |
| 373 | + consumer_ods = "CONSUMER" |
| 374 | + provider_endpoint = "https://provider.example/ep" |
| 375 | + |
| 376 | + _setup_pds_sds_mocks(mocker, nhs_number, provider_ods, provider_endpoint) |
| 377 | + mock_gp_provider = _setup_provider_mock(mocker, valid_simple_response_payload) |
| 378 | + |
| 379 | + request = create_mock_request( |
| 380 | + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, |
| 381 | + body=valid_simple_request_payload, |
| 382 | + ) |
| 383 | + |
| 384 | + controller = Controller() |
| 385 | + controller.run(GetStructuredRecordRequest(request)) |
| 386 | + |
| 387 | + mock_gp_provider.return_value.access_structured_record.assert_called_once() |
| 388 | + forwarded_body = json.loads( |
| 389 | + mock_gp_provider.return_value.access_structured_record.call_args.kwargs["body"] |
| 390 | + ) |
| 391 | + |
| 392 | + parameter_names = [p.get("name") for p in forwarded_body.get("parameter", [])] |
| 393 | + assert "identity" not in parameter_names |
| 394 | + # The patientNHSNumber parameter has no "name" key after Pydantic serialisation; |
| 395 | + # verify that exactly one parameter remains and it carries the NHS number value. |
| 396 | + remaining = forwarded_body.get("parameter", []) |
| 397 | + assert len(remaining) == 1 |
| 398 | + assert remaining[0].get("valueIdentifier", {}).get("value") == "9999999999" |
| 399 | + |
| 400 | + |
| 401 | +def _make_request_without_identity_field( |
| 402 | + consumer_ods: str, |
| 403 | + base_payload: dict[str, Any], |
| 404 | + field_to_remove: str, |
| 405 | +) -> Request: |
| 406 | + """Return a mock request with the named field removed from the identity part.""" |
| 407 | + payload = deepcopy(base_payload) |
| 408 | + identity = next(p for p in payload["parameter"] if p["name"] == "identity") |
| 409 | + identity["part"] = [p for p in identity["part"] if p["name"] != field_to_remove] |
| 410 | + return create_mock_request( |
| 411 | + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, |
| 412 | + body=payload, |
| 413 | + ) |
| 414 | + |
| 415 | + |
| 416 | +def _make_request_without_identity( |
| 417 | + consumer_ods: str, |
| 418 | + base_payload: dict[str, Any], |
| 419 | +) -> Request: |
| 420 | + """Return a mock request with the entire identity parameter removed.""" |
| 421 | + payload = deepcopy(base_payload) |
| 422 | + payload["parameter"] = [ |
| 423 | + p for p in payload["parameter"] if p.get("name") != "identity" |
| 424 | + ] |
| 425 | + return create_mock_request( |
| 426 | + headers={"ODS-From": consumer_ods, "Ssp-TraceID": "test-trace-id"}, |
| 427 | + body=payload, |
| 428 | + ) |
| 429 | + |
| 430 | + |
| 431 | +@pytest.mark.parametrize( |
| 432 | + ("missing_field", "expected_message"), |
| 433 | + [ |
| 434 | + ("issuer", "Missing 'issuer' in identity in request body"), |
| 435 | + ( |
| 436 | + "requestingOrgName", |
| 437 | + "Missing 'requestingOrgName' in identity in request body", |
| 438 | + ), |
| 439 | + ("requestingDevice", "Missing 'requestingDevice' in identity in request body"), |
| 440 | + ( |
| 441 | + "requestingPractitioner", |
| 442 | + "Missing 'requestingPractitioner' in identity in request body", |
| 443 | + ), |
| 444 | + ], |
| 445 | +) |
| 446 | +def test_controller_run_raises_value_error_when_identity_field_missing( |
| 447 | + mocker: MockerFixture, |
| 448 | + valid_simple_request_payload: dict[str, Any], |
| 449 | + valid_simple_response_payload: dict[str, Any], |
| 450 | + missing_field: str, |
| 451 | + expected_message: str, |
| 452 | +) -> None: |
| 453 | + """ |
| 454 | + Test that a ValueError is raised when a required identity field is absent. |
| 455 | + """ |
| 456 | + nhs_number = "9000000009" |
| 457 | + provider_ods = "PROVIDER" |
| 458 | + consumer_ods = "CONSUMER" |
| 459 | + provider_endpoint = "https://provider.example/ep" |
| 460 | + |
| 461 | + _setup_pds_sds_mocks(mocker, nhs_number, provider_ods, provider_endpoint) |
| 462 | + _setup_provider_mock(mocker, valid_simple_response_payload) |
| 463 | + |
| 464 | + request = _make_request_without_identity_field( |
| 465 | + consumer_ods, valid_simple_request_payload, missing_field |
| 466 | + ) |
| 467 | + |
| 468 | + controller = Controller() |
| 469 | + with pytest.raises(ValueError, match=expected_message): |
| 470 | + controller.run(GetStructuredRecordRequest(request)) |
| 471 | + |
| 472 | + |
| 473 | +def test_controller_run_raises_value_error_when_identity_parameter_absent( |
| 474 | + mocker: MockerFixture, |
| 475 | + valid_simple_request_payload: dict[str, Any], |
| 476 | + valid_simple_response_payload: dict[str, Any], |
| 477 | +) -> None: |
| 478 | + """ |
| 479 | + Test that a ValueError is raised when the identity parameter is entirely absent. |
| 480 | + """ |
| 481 | + nhs_number = "9000000009" |
| 482 | + provider_ods = "PROVIDER" |
| 483 | + consumer_ods = "CONSUMER" |
| 484 | + provider_endpoint = "https://provider.example/ep" |
| 485 | + |
| 486 | + _setup_pds_sds_mocks(mocker, nhs_number, provider_ods, provider_endpoint) |
| 487 | + _setup_provider_mock(mocker, valid_simple_response_payload) |
| 488 | + |
| 489 | + request = _make_request_without_identity(consumer_ods, valid_simple_request_payload) |
| 490 | + |
| 491 | + controller = Controller() |
| 492 | + with pytest.raises( |
| 493 | + ValueError, match="Missing 'issuer' in identity in request body" |
| 494 | + ): |
| 495 | + controller.run(GetStructuredRecordRequest(request)) |
0 commit comments