diff --git a/services/graph/app/fsma/queries.py b/services/graph/app/fsma/queries.py index 6b873d956..5ce461c2a 100644 --- a/services/graph/app/fsma/queries.py +++ b/services/graph/app/fsma/queries.py @@ -138,6 +138,7 @@ async def query_events_by_range( product_contains: Optional[str] = None, facility_contains: Optional[str] = None, cte_type: Optional[str] = None, + tlc: Optional[str] = None, limit: int = 500, starting_after: Optional[str] = None, ) -> List[Dict[str, Any]]: @@ -152,6 +153,9 @@ async def query_events_by_range( start_date: Start date (ISO format YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) end_date: End date tenant_id: Tenant UUID (required, non-empty) + tlc: Optional Traceability Lot Code. When set, only events whose + joined Lot has exactly this ``tlc`` are returned (no trimming + or case folding). Returns: List of event objects with lot and facility details @@ -194,7 +198,8 @@ async def query_events_by_range( WHERE f.tenant_id = $tenant_id WITH e, l, f - WHERE ( + WHERE ($tlc IS NULL OR l.tlc = $tlc) + AND ( $product_contains IS NULL OR toLower(coalesce(l.product_description, '')) CONTAINS toLower($product_contains) ) @@ -234,6 +239,7 @@ async def query_events_by_range( product_contains=product_contains, facility_contains=facility_contains, cte_type=cte_type, + tlc=tlc, limit=max(1, min(1000, int(limit))), starting_after=starting_after, ) @@ -295,6 +301,7 @@ async def query_events_by_range( cte_type=cte_type, has_product_filter=bool(product_contains), has_facility_filter=bool(facility_contains), + has_tlc_filter=tlc is not None, ) return events diff --git a/services/graph/app/routers/fsma/compliance.py b/services/graph/app/routers/fsma/compliance.py index 42a918ea3..f6562d446 100644 --- a/services/graph/app/routers/fsma/compliance.py +++ b/services/graph/app/routers/fsma/compliance.py @@ -97,6 +97,12 @@ async def export_fda_request_sheet( # RE-SEC-003: constrain shape of strings that flow into Content-Disposition. start_date: str = Query(..., pattern=r"^[0-9T:.\-]{1,32}$", description="Start Date (YYYY-MM-DD or ISO)"), end_date: str = Query(..., pattern=r"^[0-9T:.\-]{1,32}$", description="End Date (YYYY-MM-DD or ISO)"), + tlc: Optional[str] = Query( + None, + min_length=1, + max_length=256, + description="Traceability Lot Code. When set, only events for this exact lot are exported.", + ), tenant_id: uuid.UUID = Depends(get_current_tenant_id), api_key=Depends(require_api_key), ): @@ -109,7 +115,7 @@ async def export_fda_request_sheet( Required by FSMA 204 Section 1.1455(b)(3) - must be provided within 24 hours. """ - logger.info("fda_request_export_initiated", start=start_date, end=end_date) + logger.info("fda_request_export_initiated", start=start_date, end=end_date, has_tlc_filter=tlc is not None) # #2511: reconcile the two tenant sources (middleware vs authenticated # API key) and scope Neo4j to the authenticated principal — fail closed @@ -126,7 +132,7 @@ async def export_fda_request_sheet( # serialization below it raised, leaking a driver (and pool) # per failed request behind the generic 500 handler. async with Neo4jClient(database=db_name) as client: - events = await query_events_by_range(client, start_date, end_date, scoped_tenant_id) + events = await query_events_by_range(client, start_date, end_date, scoped_tenant_id, tlc=tlc) csv_buffer = io.StringIO() writer = csv.writer(csv_buffer) diff --git a/services/graph/tests/test_fda_request_export_tlc_filter.py b/services/graph/tests/test_fda_request_export_tlc_filter.py new file mode 100644 index 000000000..77120caec --- /dev/null +++ b/services/graph/tests/test_fda_request_export_tlc_filter.py @@ -0,0 +1,129 @@ +"""Regression tests: the FDA-request export honours an exact ``tlc`` filter. + +``RegEngineClient.export_fda`` (``regengine/client.py``) sends ``tlc`` to +``GET /v1/fsma/compliance/export/fda-request``. The route used to accept only +``start_date`` and ``end_date`` and called ``query_events_by_range`` without a +lot, so the CSV held every tenant event in the date range, other lots +included. The route now forwards ``tlc`` unchanged and the query filters on +``l.tlc = $tlc`` as a bound parameter. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import date, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "shared")) + +from datetime import datetime, timezone # noqa: E402 + +from shared.auth import APIKey, require_api_key # noqa: E402 +from shared.middleware import get_current_tenant_id # noqa: E402 + +from services.graph.app.fsma.queries import query_events_by_range # noqa: E402 +from services.graph.tests.test_fda_request_export_header import _build_app, _neo4j_stub # noqa: E402 + +URL = "/v1/fsma/compliance/export/fda-request" +_TODAY = date.today() +START_DATE = (_TODAY - timedelta(days=30)).isoformat() +END_DATE = _TODAY.isoformat() +DATES = {"start_date": START_DATE, "end_date": END_DATE} + + +def _lot_code() -> str: + """A lot code made at runtime, mixed case so case handling is visible.""" + return f"Lot-{uuid.uuid4().hex[:8]}" + + +@pytest.fixture(autouse=True) +def _clean_slowapi_bucket(): + """Keep this file's GETs out of the shared 10/min export quota.""" + from shared.rate_limit import limiter + + reset = getattr(limiter._storage, "reset", None) + if callable(reset): + reset() + yield + reset = getattr(limiter._storage, "reset", None) + if callable(reset): + reset() + + +def _call_route(params): + query = AsyncMock(return_value=[]) + tenant = uuid.uuid4() + app = _build_app() + app.dependency_overrides[require_api_key] = lambda: APIKey( + key_id="preshared-master", + key_hash="", + name="test", + tenant_id=str(tenant), + created_at=datetime.now(timezone.utc), + ) + app.dependency_overrides[get_current_tenant_id] = lambda: tenant + client = TestClient(app, raise_server_exceptions=False) + with ( + patch("services.graph.app.routers.fsma.compliance.Neo4jClient", _neo4j_stub()), + patch("services.graph.app.fsma_utils.query_events_by_range", new=query), + ): + resp = client.get(URL, params=params) + return resp, query + + +def test_route_forwards_exact_tlc_to_query(): + tlc = f" {_lot_code()} " + resp, query = _call_route({**DATES, "tlc": tlc}) + assert resp.status_code == 200, resp.text + assert query.await_args.kwargs["tlc"] == tlc + + +def test_route_without_tlc_keeps_date_range_export(): + resp, query = _call_route(DATES) + assert resp.status_code == 200, resp.text + assert query.await_args.kwargs["tlc"] is None + + +def test_route_rejects_empty_tlc(): + resp, _ = _call_route({**DATES, "tlc": ""}) + assert resp.status_code == 422 + + +def _client_with_session(): + session = MagicMock() + result = MagicMock() + result.__aiter__.return_value = iter([]) + session.run = AsyncMock(return_value=result) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=False) + client = MagicMock() + client.session.return_value = ctx + return client, session + + +@pytest.mark.asyncio +async def test_query_binds_tlc_as_exact_lot_filter(): + client, session = _client_with_session() + lot = _lot_code() + upper = lot.upper() + assert upper != lot + await query_events_by_range(client, START_DATE, END_DATE, str(uuid.uuid4()), tlc=upper) + args = session.run.await_args + assert "l.tlc = $tlc" in args.args[0] + assert args.kwargs["tlc"] == upper + assert args.kwargs["tlc"] != lot + + +@pytest.mark.asyncio +async def test_query_without_tlc_binds_null(): + client, session = _client_with_session() + await query_events_by_range(client, START_DATE, END_DATE, str(uuid.uuid4())) + assert session.run.await_args.kwargs["tlc"] is None