Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion services/graph/app/fsma/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand All @@ -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
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions services/graph/app/routers/fsma/compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
):
Expand All @@ -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
Expand All @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions services/graph/tests/test_fda_request_export_tlc_filter.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
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
Loading