Skip to content

Commit 0a39b03

Browse files
committed
Log rejected bundle entries as invalid requests
1 parent a6396a7 commit 0a39b03

4 files changed

Lines changed: 90 additions & 47 deletions

File tree

‎app/errors/handlers.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from fastapi.responses import JSONResponse
88
from gfmodules.logging.exceptions import log_unhandled_exception
99
from gfmodules.logging.middleware import restore_request_context
10+
from pydantic import ValidationError
1011

1112
from app.errors.fhir import FHIRError
1213
from app.errors.mapping import DEFAULT_SPEC, EXCEPTION_MAP, UNEXPECTED_ERROR_MESSAGE, spec_for
@@ -23,8 +24,8 @@
2324
_DENIAL_STATUSES = frozenset({401, 403})
2425

2526

26-
def _summarize_reason(exc: Exception) -> str:
27-
if isinstance(exc, RequestValidationError):
27+
def summarize_reason(exc: Exception) -> str:
28+
if isinstance(exc, (RequestValidationError, ValidationError)):
2829
reasons = [
2930
f"{'.'.join(str(loc) for loc in err.get('loc', ()))}: {err.get('msg', '')}".strip(" :")
3031
for err in exc.errors()
@@ -40,7 +41,7 @@ def log_rejection(status_code: int, exc: Exception) -> None:
4041
"Request rejected with %s (%s): %s",
4142
status_code,
4243
type(exc).__name__,
43-
_summarize_reason(exc),
44+
summarize_reason(exc),
4445
)
4546

4647

@@ -55,7 +56,7 @@ def log_invalid_request(status_code: int, reason: str) -> None:
5556

5657
def _log_invalid_request(request: Request, status_code: int, exc: Exception) -> None:
5758
with bind_auth_claims(getattr(request.state, "auth", None)):
58-
log_invalid_request(status_code, _summarize_reason(exc))
59+
log_invalid_request(status_code, summarize_reason(exc))
5960

6061

6162
def handle_mapped_exception(request: Request, exc: Exception) -> JSONResponse:

‎app/services/fhir/bundle.py‎

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import gfmodules.logging as gflog
77

8-
from app.errors.handlers import log_rejection
8+
from app.errors.handlers import log_invalid_request, log_rejection, summarize_reason
99
from app.errors.mapping import DEFAULT_SPEC, UNEXPECTED_ERROR_MESSAGE, spec_for
1010
from app.logging.events import Log
1111
from app.models.auth.context import AuthContext
@@ -28,6 +28,11 @@
2828
logger = logging.getLogger(__name__)
2929

3030

31+
def _invalid_entry(msg: str, status: int = 422, code: str = "required", reason: str | None = None) -> BundleEntry[Any]:
32+
log_invalid_request(status, reason or msg)
33+
return BundleEntry(response=EntryResponse.make_error_response(msg, str(status), code))
34+
35+
3136
class BundleService:
3237
def __init__(
3338
self,
@@ -37,14 +42,10 @@ def __init__(
3742

3843
def process_entry(self, ctx: AuthContext, entry: BundleEntry[Any], index: int) -> BundleEntry[Any]:
3944
if entry.request is None:
40-
return BundleEntry(
41-
response=EntryResponse.make_validation_response(f"Bundle.entry.{index}.request is required")
42-
)
45+
return _invalid_entry(f"Bundle.entry.{index}.request is required")
4346

4447
if entry.request.method is None:
45-
return BundleEntry(
46-
response=EntryResponse.make_validation_response(f"Bundle.entry.{index}.request.method is required")
47-
)
48+
return _invalid_entry(f"Bundle.entry.{index}.request.method is required")
4849

4950
method = entry.request.method
5051
resolved_url = self.resolve_request_url(entry.request.url, index)
@@ -151,6 +152,9 @@ def _entry_result(self, index: int, action: Callable[[], BundleEntry[Any]]) -> B
151152
else:
152153
log_rejection(spec.http_status, exc)
153154

155+
if spec.invalid_input:
156+
return _invalid_entry(f"Bundle.entry.{index}: {exc}", spec.http_status, spec.fhir_code)
157+
154158
return BundleEntry(
155159
response=EntryResponse.make_error_response(
156160
msg=f"Bundle.entry.{index}: {UNEXPECTED_ERROR_MESSAGE if unexpected else exc}",
@@ -184,11 +188,8 @@ def _parse_params(
184188
try:
185189
return model.model_validate(resolved_url.params)
186190
except ValueError as e:
187-
return BundleEntry(
188-
response=EntryResponse.make_validation_response(
189-
f"Bundle.entry.{index}.request: invalid url parameter: {e}"
190-
)
191-
)
191+
prefix = f"Bundle.entry.{index}.request: invalid url parameter"
192+
return _invalid_entry(f"{prefix}: {e}", reason=f"{prefix}: {summarize_reason(e)}")
192193

193194
def _deny_mismatched_source(
194195
self,
@@ -208,13 +209,7 @@ def _deny_mismatched_source(
208209
# A source built on the wrong naming system, which the standalone route rejects
209210
# before it reaches the service too.
210211
spec = spec_for(e)
211-
return BundleEntry(
212-
response=EntryResponse.make_error_response(
213-
msg=f"Bundle.entry.{index}: {e}",
214-
status=str(spec.http_status),
215-
code=spec.fhir_code,
216-
)
217-
)
212+
return _invalid_entry(f"Bundle.entry.{index}: {e}", spec.http_status, spec.fhir_code)
218213

219214
try:
220215
AuthContextService.assert_source_matches(ctx, entry_source)
@@ -231,14 +226,10 @@ def _deny_mismatched_source(
231226
def _validate_post_resource(self, entry: BundleEntry[Any], index: int) -> LocalizationList | BundleEntry[Any]:
232227
"""Return the validated ``LocalizationList`` or an error ``BundleEntry``."""
233228
if entry.resource is None:
234-
return BundleEntry(
235-
response=EntryResponse.make_validation_response(msg=f"Bundle.entry.{index}: resource cannot be empty")
236-
)
229+
return _invalid_entry(f"Bundle.entry.{index}: resource cannot be empty")
237230

238231
if not isinstance(entry.resource, LocalizationList):
239-
return BundleEntry(
240-
response=EntryResponse.make_validation_response(f"Bundle.entry.{index}: invalid List resource")
241-
)
232+
return _invalid_entry(f"Bundle.entry.{index}: invalid List resource")
242233

243234
return entry.resource
244235

@@ -276,27 +267,15 @@ def resolve_request_url(self, url: str, index: int) -> EntryRequestDto | BundleE
276267
try:
277268
request_dto = EntryRequestDto.from_url(url)
278269
except ValueError:
279-
return BundleEntry(
280-
response=EntryResponse.make_validation_response(f"Bundle.entry.{index}.request.url is malformed")
281-
)
270+
return _invalid_entry(f"Bundle.entry.{index}.request.url is malformed")
282271

283272
if request_dto.resource is None:
284-
return BundleEntry(
285-
response=EntryResponse.make_validation_response(f"Bundle.entry.{index}.request.url: resource not found")
286-
)
273+
return _invalid_entry(f"Bundle.entry.{index}.request.url: resource not found")
287274

288275
if request_dto.resource != "List":
289-
return BundleEntry(
290-
response=EntryResponse.make_validation_response(
291-
f"Bundle.entry.{index}.request.url unsupported {request_dto.resource} resource"
292-
)
293-
)
276+
return _invalid_entry(f"Bundle.entry.{index}.request.url unsupported {request_dto.resource} resource")
294277

295278
if request_dto.id is not None and request_dto.params is not None:
296-
return BundleEntry(
297-
response=EntryResponse.make_validation_response(
298-
f"Bundle.entry.{index}.request.url: unsupported url for requested transaction"
299-
)
300-
)
279+
return _invalid_entry(f"Bundle.entry.{index}.request.url: unsupported url for requested transaction")
301280

302281
return request_dto

‎tests/routers/test_fhir_base.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from typing import Any
2+
from unittest.mock import Mock
23

34
import pytest
45
from fastapi.testclient import TestClient
6+
from gfmodules.logging.testing import assert_event_emitted, capture_records
57

68
from app.debug.crypto_service_api_client_mock import CryptoServiceApiClientMock
9+
from app.logging.events import Log
10+
from app.services.exceptions import PseudonymExchangeError
711
from app.services.key_info import KeyInfoService
812
from app.services.referral_service import ReferralService
913
from tests.conftest import make_list_resource
@@ -95,3 +99,61 @@ def test_processes_a_get_query_entry(self, client: TestClient) -> None:
9599
entry = response.json()["entry"][0]
96100
assert entry["response"]["status"] == "200"
97101
assert entry["resource"]["resourceType"] == "Bundle"
102+
103+
104+
_NO_RESOURCE = {"request": {"method": "POST", "url": "List"}}
105+
106+
107+
def _wrong_ura_entry() -> dict[str, Any]:
108+
entry = _post_entry()
109+
entry["resource"]["extension"][0]["valueReference"]["identifier"]["system"] = "urn:wrong"
110+
return entry
111+
112+
113+
class TestInvalidEntryLogging:
114+
@pytest.mark.parametrize(
115+
("entry", "status"),
116+
[
117+
(_NO_RESOURCE, 422),
118+
(_wrong_ura_entry(), 400),
119+
({"request": {"method": "GET", "url": "Patient"}}, 422),
120+
],
121+
ids=["missing-resource", "wrong-ura-system", "unsupported-resource"],
122+
)
123+
def test_logs_a_rejected_entry(self, client: TestClient, entry: dict[str, Any], status: int) -> None:
124+
with capture_records() as records:
125+
response = client.post("/fhir", json=_bundle(entry), headers=FHIR_JSON)
126+
127+
assert response.json()["entry"][0]["response"]["status"] == str(status)
128+
assert_event_emitted(records, Log.INVALID_REQUEST, organization_identifier=TEST_URA, http_status=status)
129+
130+
def test_logs_only_the_invalid_entries_of_a_mixed_bundle(self, client: TestClient) -> None:
131+
with capture_records() as records:
132+
response = client.post(
133+
"/fhir", json=_bundle(_post_entry(), _NO_RESOURCE, _wrong_ura_entry()), headers=FHIR_JSON
134+
)
135+
136+
assert [e["response"]["status"] for e in response.json()["entry"]] == ["201", "422", "400"]
137+
assert len(records.for_event(Log.INVALID_REQUEST)) == 2
138+
139+
def test_keeps_the_subject_token_out_of_a_rejected_entry_log(self, client: TestClient) -> None:
140+
entry = {"request": {"method": "DELETE", "url": "List?subject:identifier=urn:wrong|leaky-token-value"}}
141+
with capture_records() as records:
142+
response = client.post("/fhir", json=_bundle(entry), headers=FHIR_JSON)
143+
144+
assert response.json()["entry"][0]["response"]["status"] == "422"
145+
logged = assert_event_emitted(records, Log.INVALID_REQUEST, http_status=422)
146+
assert "leaky-token-value" not in logged.message["error_reason"]
147+
148+
def test_logs_a_rejected_exchange_in_an_entry_only_as_a_failed_registration(
149+
self, client: TestClient, crypto_client: CryptoServiceApiClientMock, monkeypatch: pytest.MonkeyPatch
150+
) -> None:
151+
rejected = PseudonymExchangeError("Invalid pseudonym or oprf_key")
152+
monkeypatch.setattr(crypto_client, "exchange", Mock(side_effect=rejected))
153+
154+
with capture_records() as records:
155+
response = client.post("/fhir", json=_bundle(_post_entry()), headers=FHIR_JSON)
156+
157+
assert response.json()["entry"][0]["response"]["status"] == "400"
158+
assert len(records.for_event(Log.REFERRAL_REGISTRATION_FAILED)) == 1
159+
assert records.for_event(Log.INVALID_REQUEST) == []

‎tests/services/fhir/test_bundle_service.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66

7+
from app.logging.events import Log
78
from app.models.auth.context import AuthContext, AuthenticationClaims
89
from app.models.auth.data import AuthorizationScope
910
from app.models.fhir.bundle import BundleEntry, EntryRequest
@@ -510,7 +511,7 @@ def test_failed_own_query_is_not_a_localization_failure(
510511
with caplog.at_level(logging.INFO):
511512
process(bundle_service, make_entry("GET", own_query_url), [AuthorizationScope.READ])
512513

513-
assert _event_ids(caplog) == []
514+
assert _event_ids(caplog) == [Log.INVALID_REQUEST.event_id]
514515

515516
def test_failed_delete_by_query_is_not_a_localization_failure(
516517
self,
@@ -523,7 +524,7 @@ def test_failed_delete_by_query_is_not_a_localization_failure(
523524
with caplog.at_level(logging.INFO):
524525
process(bundle_service, make_entry("DELETE", QUERY_URL), [AuthorizationScope.DELETE])
525526

526-
assert _event_ids(caplog) == []
527+
assert _event_ids(caplog) == [Log.INVALID_REQUEST.event_id]
527528

528529

529530
class TestSourceMatch:

0 commit comments

Comments
 (0)