From d36330d39e4d23800fbd2f5777615b180cd39bb4 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:47:56 -0700 Subject: [PATCH 1/3] fix(graph): filter the FDA request export by exact TLC export_fda in regengine/client.py sends tlc, but the fda-request route accepted only start_date and end_date and exported every tenant event in the range, other lots included. The route now takes an optional tlc (1 to 256 chars, passed through unchanged) and query_events_by_range filters on l.tlc = $tlc as a bound parameter. Without tlc the export keeps the date range behaviour. Adds regression tests for the route and the query. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- services/graph/app/fsma/queries.py | 9 +- services/graph/app/routers/fsma/compliance.py | 10 +- .../test_fda_request_export_tlc_filter.py | 101 ++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 services/graph/tests/test_fda_request_export_tlc_filter.py 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..afff03e20 --- /dev/null +++ b/services/graph/tests/test_fda_request_export_tlc_filter.py @@ -0,0 +1,101 @@ +"""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 +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 services.graph.tests.test_fda_request_export_header import TENANT, _build_app, _neo4j_stub # noqa: E402 + +from services.graph.app.fsma.queries import query_events_by_range # noqa: E402 + +URL = "/v1/fsma/compliance/export/fda-request" +DATES = {"start_date": "2026-01-01", "end_date": "2026-12-31"} + + +@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=[]) + client = TestClient(_build_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 = " Lot-ABC-001 " + 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() + await query_events_by_range(client, "2026-01-01", "2026-12-31", TENANT, tlc="LOT-ABC-001") + args = session.run.await_args + assert "l.tlc = $tlc" in args.args[0] + assert args.kwargs["tlc"] == "LOT-ABC-001" + + +@pytest.mark.asyncio +async def test_query_without_tlc_binds_null(): + client, session = _client_with_session() + await query_events_by_range(client, "2026-01-01", "2026-12-31", TENANT) + assert session.run.await_args.kwargs["tlc"] is None From c00c3426a1e52a520b3e0a5f3a4dc2bd3f9a4d85 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:48:21 -0700 Subject: [PATCH 2/3] style(graph): sort imports in the TLC filter test Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- services/graph/tests/test_fda_request_export_tlc_filter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/graph/tests/test_fda_request_export_tlc_filter.py b/services/graph/tests/test_fda_request_export_tlc_filter.py index afff03e20..012dcdf96 100644 --- a/services/graph/tests/test_fda_request_export_tlc_filter.py +++ b/services/graph/tests/test_fda_request_export_tlc_filter.py @@ -21,9 +21,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[3])) sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "shared")) -from services.graph.tests.test_fda_request_export_header import TENANT, _build_app, _neo4j_stub # 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 TENANT, _build_app, _neo4j_stub # noqa: E402 URL = "/v1/fsma/compliance/export/fda-request" DATES = {"start_date": "2026-01-01", "end_date": "2026-12-31"} From 86079c5ecde3b7cccceda196c112c5f55c41283e Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:54:54 -0700 Subject: [PATCH 3/3] test(graph): build tlc filter test data at runtime Lot codes, dates and the tenant id are generated per run (uuid4 and today based dates) instead of fixed literals and the placeholder tenant. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../test_fda_request_export_tlc_filter.py | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/services/graph/tests/test_fda_request_export_tlc_filter.py b/services/graph/tests/test_fda_request_export_tlc_filter.py index 012dcdf96..77120caec 100644 --- a/services/graph/tests/test_fda_request_export_tlc_filter.py +++ b/services/graph/tests/test_fda_request_export_tlc_filter.py @@ -11,6 +11,8 @@ from __future__ import annotations import sys +import uuid +from datetime import date, timedelta from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -21,11 +23,24 @@ 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 TENANT, _build_app, _neo4j_stub # 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" -DATES = {"start_date": "2026-01-01", "end_date": "2026-12-31"} +_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) @@ -44,7 +59,17 @@ def _clean_slowapi_bucket(): def _call_route(params): query = AsyncMock(return_value=[]) - client = TestClient(_build_app(), raise_server_exceptions=False) + 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), @@ -54,7 +79,7 @@ def _call_route(params): def test_route_forwards_exact_tlc_to_query(): - tlc = " Lot-ABC-001 " + 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 @@ -87,14 +112,18 @@ def _client_with_session(): @pytest.mark.asyncio async def test_query_binds_tlc_as_exact_lot_filter(): client, session = _client_with_session() - await query_events_by_range(client, "2026-01-01", "2026-12-31", TENANT, tlc="LOT-ABC-001") + 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"] == "LOT-ABC-001" + 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, "2026-01-01", "2026-12-31", TENANT) + await query_events_by_range(client, START_DATE, END_DATE, str(uuid.uuid4())) assert session.run.await_args.kwargs["tlc"] is None