diff --git a/services/ingestion/app/epcis_export.py b/services/ingestion/app/epcis_export.py index d5a1f0859..88bf999b0 100644 --- a/services/ingestion/app/epcis_export.py +++ b/services/ingestion/app/epcis_export.py @@ -484,7 +484,8 @@ class ExportRequest(BaseModel): summary="Export in GS1 EPCIS 2.0 JSON-LD format", description=( "Generates a GS1 EPCIS 2.0 compliant JSON-LD document containing all " - "traceability events. Compatible with Walmart, Kroger, and Costco portals." + "traceability events. Compatible with Walmart, Kroger, and Costco portals. " + "Stored event hashes are included without cryptographic verification." ), ) async def export_epcis( @@ -525,10 +526,7 @@ def _skip(row: dict, reason: str) -> None: _skip(r, "missing_timestamp") continue event_kind = r.get("epcis_event_type") - is_transformation = ( - r.get("event_type") == "transformation" - or event_kind == "TransformationEvent" - ) + is_transformation = r.get("event_type") == "transformation" or event_kind == "TransformationEvent" if event_kind not in (None, "ObjectEvent", "TransformationEvent"): _skip(r, "unsupported_epcis_event_type") continue @@ -644,8 +642,9 @@ def _skip(row: dict, reason: str) -> None: "skippedEventsByReason": skipped_by_reason, "skippedEvents": skipped_evidence, "dataSource": data_source, - "integrityVerified": data_source == "tenant", - "chainHashVerified": data_source == "tenant", + # Loading stored hashes does not verify event content or chain integrity. + "integrityVerified": False, + "chainHashVerified": False, **({"regengine:message": "No data yet"} if not rows else {}), }, } @@ -679,7 +678,7 @@ def _skip(row: dict, reason: str) -> None: ), "X-RegEngine-Events-Count": str(len(event_list)), "X-RegEngine-Skipped-Events-Count": str(skipped), - "X-RegEngine-Integrity-Verified": "true" if data_source == "tenant" else "false", + "X-RegEngine-Integrity-Verified": "false", "X-RegEngine-Data-Source": data_source, } if validate: diff --git a/services/ingestion/tests/test_epcis_export_1342.py b/services/ingestion/tests/test_epcis_export_1342.py index 0f693244b..9437fffac 100644 --- a/services/ingestion/tests/test_epcis_export_1342.py +++ b/services/ingestion/tests/test_epcis_export_1342.py @@ -219,8 +219,8 @@ def test_returns_tenant_events_when_db_has_rows(self, client: TestClient, monkey body = response.json() metadata = body["regengine:exportMetadata"] assert metadata["dataSource"] == "tenant" - assert metadata["integrityVerified"] is True - assert metadata["chainHashVerified"] is True + assert metadata["integrityVerified"] is False + assert metadata["chainHashVerified"] is False assert metadata["eventsCount"] == 1 assert "regengine:disclaimer" not in metadata events = body["epcisBody"]["eventList"] @@ -232,9 +232,43 @@ def test_returns_tenant_events_when_db_has_rows(self, client: TestClient, monkey assert event["quantityList"][0]["epcClass"] == row["epc_class"] assert metadata["skippedEventsCount"] == 0 assert response.headers["X-RegEngine-Data-Source"] == "tenant" - assert response.headers["X-RegEngine-Integrity-Verified"] == "true" + assert response.headers["X-RegEngine-Integrity-Verified"] == "false" assert response.headers["X-RegEngine-Events-Count"] == "1" + @pytest.mark.parametrize("validate", [False, True]) + def test_corrupted_stored_hash_remains_unverified( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch, validate: bool + ) -> None: + # A well-formed but wrong hash must not acquire a verification claim + # just because it was loaded from storage or passed structural checks. + corrupted_hash = "0" * 64 + session = _FakeSession(execute_queue=[[_event_row(sha256_hash=corrupted_hash)]]) + monkeypatch.setattr(epcis_export, "get_db_safe", lambda: session) + + response = client.post( + "/api/v1/export/epcis", + params={"validate": str(validate).lower()}, + json={"tenant_id": str(uuid4()), "format": "epcis"}, + ) + + assert response.status_code == 200 + body = response.json() + metadata = body["regengine:exportMetadata"] + assert metadata["dataSource"] == "tenant" + assert metadata["eventsCount"] == 1 + assert body["epcisBody"]["eventList"][0]["regengine:sha256"] == corrupted_hash + assert metadata["integrityVerified"] is False + assert metadata["chainHashVerified"] is False + assert response.headers["X-RegEngine-Integrity-Verified"] == "false" + assert session.closed + if validate: + assert body["regengine:validation"]["valid"] is True + assert body["regengine:validation"]["errors"] == [] + assert response.headers["X-RegEngine-Schema-Valid"] == "true" + else: + assert "regengine:validation" not in body + assert "X-RegEngine-Schema-Valid" not in response.headers + def test_null_timestamp_is_skipped_without_no_data_message( self, client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -251,6 +285,10 @@ def test_null_timestamp_is_skipped_without_no_data_message( assert meta["eventsCount"] == 0 assert meta["skippedEventsCount"] == 1 assert meta["skippedEventsByReason"]["missing_timestamp"] == 1 + assert meta["dataSource"] == "tenant" + assert meta["integrityVerified"] is False + assert meta["chainHashVerified"] is False + assert response.headers["X-RegEngine-Integrity-Verified"] == "false" assert "regengine:message" not in meta assert response.headers["X-RegEngine-Events-Count"] == "0" assert response.headers["X-RegEngine-Skipped-Events-Count"] == "1" diff --git a/services/ingestion/tests/test_legacy_router_tenant_context.py b/services/ingestion/tests/test_legacy_router_tenant_context.py index b5ebfba29..3ea0ebe55 100644 --- a/services/ingestion/tests/test_legacy_router_tenant_context.py +++ b/services/ingestion/tests/test_legacy_router_tenant_context.py @@ -3,10 +3,12 @@ from __future__ import annotations from types import SimpleNamespace +from unittest.mock import Mock import app.webhook_compat as webhook_compat +import pytest from app import epcis_export, team_mgmt -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient TENANT_A = "00000000-0000-0000-0000-0000000000aa" @@ -40,6 +42,8 @@ def test_legacy_path_tenant_mismatch_rejected_before_handler(monkeypatch): def test_legacy_json_body_tenant_mismatch_rejected_before_handler(monkeypatch): _install_scoped_key(monkeypatch, TENANT_A) + query = Mock(side_effect=AssertionError("Unauthorized export must not query storage")) + monkeypatch.setattr(epcis_export, "_query_tenant_events", query) app = FastAPI() app.include_router(epcis_export.router) @@ -51,3 +55,27 @@ def test_legacy_json_body_tenant_mismatch_rejected_before_handler(monkeypatch): assert response.status_code == 409 assert response.json()["detail"]["code"] == "E_TENANT_MISMATCH" + query.assert_not_called() + + +@pytest.mark.parametrize("headers", [{}, {"X-RegEngine-API-Key": "invalid-test-key"}]) +def test_export_auth_failure_rejected_before_storage(monkeypatch, headers): + async def _deny_key(*, request, x_regengine_api_key): + raise HTTPException(status_code=401, detail="Invalid API key") + + monkeypatch.setattr(webhook_compat, "require_api_key", _deny_key) + query = Mock(side_effect=AssertionError("Unauthorized export must not query storage")) + monkeypatch.setattr(epcis_export, "_query_tenant_events", query) + app = FastAPI() + app.include_router(epcis_export.router) + + response = TestClient(app).post( + "/api/v1/export/epcis", + headers=headers, + json={"tenant_id": TENANT_A, "format": "epcis"}, + ) + + assert response.status_code == 401 + assert "regengine:exportMetadata" not in response.json() + assert "X-RegEngine-Integrity-Verified" not in response.headers + query.assert_not_called()