From bd00081a2069f8e0ee79dc2eef99e822e81e5157 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Thu, 29 Oct 2026 01:38:20 -0700 Subject: [PATCH] fix(ingestion): stop asserting unchecked export integrity --- services/ingestion/app/epcis_export.py | 10 +++-- .../ingestion/tests/test_epcis_export_1342.py | 40 +++++++++++++++++-- .../test_legacy_router_tenant_context.py | 30 +++++++++++++- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/services/ingestion/app/epcis_export.py b/services/ingestion/app/epcis_export.py index 278def6f3..c5475b8a0 100644 --- a/services/ingestion/app/epcis_export.py +++ b/services/ingestion/app/epcis_export.py @@ -212,7 +212,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( @@ -269,8 +270,9 @@ async def export_epcis( "generatedAt": now.isoformat(), "eventsCount": len(event_list), "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 data_source == "empty" else {}), }, } @@ -297,7 +299,7 @@ async def export_epcis( + '"' ), "X-RegEngine-Events-Count": str(len(event_list)), - "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 f60db1b8b..348e00311 100644 --- a/services/ingestion/tests/test_epcis_export_1342.py +++ b/services/ingestion/tests/test_epcis_export_1342.py @@ -188,8 +188,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"] @@ -199,9 +199,43 @@ def test_returns_tenant_events_when_db_has_rows(self, client: TestClient, monkey for key in ("type", "action", "bizStep", "eventTime"): assert isinstance(event[key], str) and event[key] 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": "t", "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_fills_defaults_when_row_fields_are_null(self, client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: row = dict(_event_row(event_timestamp=None)._mapping) monkeypatch.setattr(epcis_export, "_query_tenant_events", lambda *a, **k: [row]) 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()