From 120f8b353c66650d25ce69fc11ab156a222f477a Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 22:24:45 -0700 Subject: [PATCH 1/8] Remove made-up sample files from frontend/public/samples Keep sample_recall_drill.csv (served by /proof/recall-drill and the ingestion proof_recall_router). Remove the other synthetic samples, the ingest-page link to the sample EPCIS file, the QA scripts that only validated those samples, their .secrets.baseline entries, the orphaned samples zip .gitignore rule, and the 'synthetic artifacts' claim in PRODUCT_DESCRIPTION. Signed-off-by: Christopher Sellers <1@christophersellers.com> --- .github/workflows/qa-pipeline.yml | 9 - .gitignore | 3 +- .secrets.baseline | 342 ------------------ docs/PRODUCT_DESCRIPTION.md | 2 +- docs/engineering/TRACEABILITY_FILE_INTAKE.md | 3 +- .../samples/regengine_sample_export.zip | Bin 6027 -> 0 bytes .../samples/sample_chain_verification.json | 109 ------ frontend/public/samples/sample_epcis_2.0.json | 246 ------------- frontend/public/samples/sample_fda_export.csv | 13 - frontend/public/samples/sample_manifest.json | 32 -- .../public/samples/sandbox_sample_ftl.csv | 9 - .../src/__tests__/pages/ingest-epcis.test.tsx | 27 +- frontend/src/app/ingest/page.tsx | 7 - qa/README.md | 11 - qa/decision.js | 6 - qa/export-validate.js | 125 ------- qa/fsma-lite-check.js | 183 ---------- qa/full-flow.js | 140 ------- qa/summary.js | 3 - 19 files changed, 22 insertions(+), 1248 deletions(-) delete mode 100644 frontend/public/samples/regengine_sample_export.zip delete mode 100644 frontend/public/samples/sample_chain_verification.json delete mode 100644 frontend/public/samples/sample_epcis_2.0.json delete mode 100644 frontend/public/samples/sample_fda_export.csv delete mode 100644 frontend/public/samples/sample_manifest.json delete mode 100644 frontend/public/samples/sandbox_sample_ftl.csv delete mode 100644 qa/export-validate.js delete mode 100644 qa/fsma-lite-check.js delete mode 100644 qa/full-flow.js diff --git a/.github/workflows/qa-pipeline.yml b/.github/workflows/qa-pipeline.yml index 84ac1f3ec..f6b662cfd 100644 --- a/.github/workflows/qa-pipeline.yml +++ b/.github/workflows/qa-pipeline.yml @@ -43,9 +43,6 @@ jobs: # checks use only Node built-ins; keep the existing job/check name for # consumers without repeating the dependency install and unit suite. - - name: FSMA 204 compliance checks - run: node qa/fsma-lite-check.js - # #2824: renamed. This step is a static text scan of frontend sources -- # credential hygiene and auth-wiring conventions. It does NOT verify # tenant isolation, which is enforced by PostgreSQL RLS policies that a @@ -67,15 +64,9 @@ jobs: with: node-version-file: .nvmrc - - name: Full ingestion-to-export flow - run: node qa/full-flow.js - - name: Bad data rejection run: node qa/bad-data.js - - name: Export artifact validation - run: node qa/export-validate.js - # ── Stage 3: AI Analysis ─────────────────────────────── # Advisory-only: copy, pricing, and credibility warnings for human # follow-up. Its result is intentionally NOT in decision.needs, so a diff --git a/.gitignore b/.gitignore index 5a28737a2..dad73ecbb 100644 --- a/.gitignore +++ b/.gitignore @@ -85,12 +85,11 @@ secrets.json # Artifacts that should never be tracked *.zip -# ...except the public proof/sample bundles the frontend serves. These are +# ...except the public proof bundles the frontend serves. These are # intentionally tracked fixtures (the audit-bundle pair backs the public # /api/v1/proof/verify demo, including the deliberately tampered one), and # the bare *.zip rule above was silently covering them (#2873). !frontend/public/proof/*.zip -!frontend/public/samples/*.zip login_resp.json frontend/build_log.txt frontend/build_output.txt diff --git a/.secrets.baseline b/.secrets.baseline index 38bb04d34..6638ef571 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2162,348 +2162,6 @@ "is_secret": false } ], - "frontend/public/samples/sample_chain_verification.json": [ - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "b03e7ca89182d9affd88ded6fb9eb6347417e446", - "is_verified": false, - "line_number": 9 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "2818c1177db08a2b179769e8e23322ed6c57c213", - "is_verified": false, - "line_number": 15 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "b4aa62da0e2dfe0d2c5151c3531f501d677992dc", - "is_verified": false, - "line_number": 23 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "a7c47fb08160ee110b058fa6479061556100c387", - "is_verified": false, - "line_number": 24 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "7500610854ad7efbe52b67944cd235541483200c", - "is_verified": false, - "line_number": 31 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "14c91ff72e5d5dd4ca4903849461da13e3def2ec", - "is_verified": false, - "line_number": 32 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "de2b003be06f5e88de30b4af259792ee4f75ecd2", - "is_verified": false, - "line_number": 39 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "7a01d7e8628467837b2d0d7f873ac0ae405c115f", - "is_verified": false, - "line_number": 40 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "2652722375d0b0b824542fb98f65a60835b6d82a", - "is_verified": false, - "line_number": 47 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "1e91cb8ed2934ea1e361195c24027cc824f0ebd0", - "is_verified": false, - "line_number": 48 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "77541cd6fce0590784ef7fa6674b83d64e0e7953", - "is_verified": false, - "line_number": 55 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "374b86c55dafbdc10431415d1280d4e304dc62e0", - "is_verified": false, - "line_number": 56 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "b6817e2e5b876431166d2972c464c3427eaa2710", - "is_verified": false, - "line_number": 63 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "731918cc22bb6b820fb666c07aac661b6b3ac0b3", - "is_verified": false, - "line_number": 64 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "3d006e5b523512a8960af7c2057f1fb68e039b18", - "is_verified": false, - "line_number": 71 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "40f81f7c5d609d0dbffde6a6e9f1b71ece1dac19", - "is_verified": false, - "line_number": 72 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "6f8ad6f2c0c1befc64ff3f8870aeba5589374b4d", - "is_verified": false, - "line_number": 79 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "32e8a8bce5e7f39ea20a8c5114846c9ec681b23d", - "is_verified": false, - "line_number": 80 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "0a4988099f362f887180bf5fbac2ae9c920e260b", - "is_verified": false, - "line_number": 87 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "00a2b4ca2c5dde1a07946db9bf7bbf77ddb46302", - "is_verified": false, - "line_number": 88 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "c62bb43176a3891caee998b376e7a42a6c15b6de", - "is_verified": false, - "line_number": 95 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "21764570e14232c0cf7661adae27ff40f3f87b86", - "is_verified": false, - "line_number": 96 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_chain_verification.json", - "hashed_secret": "aa48fdb42b1a1165bbdc878ccd1f6c4574d02cbd", - "is_verified": false, - "line_number": 103 - } - ], - "frontend/public/samples/sample_epcis_2.0.json": [ - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "2818c1177db08a2b179769e8e23322ed6c57c213", - "is_verified": false, - "line_number": 24 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "b4aa62da0e2dfe0d2c5151c3531f501d677992dc", - "is_verified": false, - "line_number": 43 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "a7c47fb08160ee110b058fa6479061556100c387", - "is_verified": false, - "line_number": 44 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "7500610854ad7efbe52b67944cd235541483200c", - "is_verified": false, - "line_number": 62 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "14c91ff72e5d5dd4ca4903849461da13e3def2ec", - "is_verified": false, - "line_number": 63 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "de2b003be06f5e88de30b4af259792ee4f75ecd2", - "is_verified": false, - "line_number": 81 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "7a01d7e8628467837b2d0d7f873ac0ae405c115f", - "is_verified": false, - "line_number": 82 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "2652722375d0b0b824542fb98f65a60835b6d82a", - "is_verified": false, - "line_number": 100 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "1e91cb8ed2934ea1e361195c24027cc824f0ebd0", - "is_verified": false, - "line_number": 101 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "77541cd6fce0590784ef7fa6674b83d64e0e7953", - "is_verified": false, - "line_number": 119 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "374b86c55dafbdc10431415d1280d4e304dc62e0", - "is_verified": false, - "line_number": 120 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "b6817e2e5b876431166d2972c464c3427eaa2710", - "is_verified": false, - "line_number": 138 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "731918cc22bb6b820fb666c07aac661b6b3ac0b3", - "is_verified": false, - "line_number": 139 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "3d006e5b523512a8960af7c2057f1fb68e039b18", - "is_verified": false, - "line_number": 157 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "40f81f7c5d609d0dbffde6a6e9f1b71ece1dac19", - "is_verified": false, - "line_number": 158 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "6f8ad6f2c0c1befc64ff3f8870aeba5589374b4d", - "is_verified": false, - "line_number": 176 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "32e8a8bce5e7f39ea20a8c5114846c9ec681b23d", - "is_verified": false, - "line_number": 177 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "0a4988099f362f887180bf5fbac2ae9c920e260b", - "is_verified": false, - "line_number": 195 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "00a2b4ca2c5dde1a07946db9bf7bbf77ddb46302", - "is_verified": false, - "line_number": 196 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "c62bb43176a3891caee998b376e7a42a6c15b6de", - "is_verified": false, - "line_number": 214 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "21764570e14232c0cf7661adae27ff40f3f87b86", - "is_verified": false, - "line_number": 215 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "aa48fdb42b1a1165bbdc878ccd1f6c4574d02cbd", - "is_verified": false, - "line_number": 233 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_epcis_2.0.json", - "hashed_secret": "b03e7ca89182d9affd88ded6fb9eb6347417e446", - "is_verified": false, - "line_number": 234 - } - ], - "frontend/public/samples/sample_manifest.json": [ - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_manifest.json", - "hashed_secret": "b03e7ca89182d9affd88ded6fb9eb6347417e446", - "is_verified": false, - "line_number": 27 - }, - { - "type": "Hex High Entropy String", - "filename": "frontend/public/samples/sample_manifest.json", - "hashed_secret": "c26716d55190fc65601e165b63b530be01e2ac6d", - "is_verified": false, - "line_number": 30 - } - ], "frontend/src/__tests__/api/admin.test.ts": [ { "type": "Secret Keyword", diff --git a/docs/PRODUCT_DESCRIPTION.md b/docs/PRODUCT_DESCRIPTION.md index 7666cb8e4..260e23188 100644 --- a/docs/PRODUCT_DESCRIPTION.md +++ b/docs/PRODUCT_DESCRIPTION.md @@ -14,7 +14,7 @@ Accepted events are stored with tenant boundaries in PostgreSQL. The platform ev Optional Jev assistance suggests mappings for unfamiliar CSV columns and a file's CTE when none is declared. Upload screens expose suggestions for operator review. Automatic application defaults off, requests use headers without row samples by default, and model suggestions do not replace deterministic validation. Preview and evaluation tools let operators inspect suggestions before enabling automatic application. -Public synthetic artifacts, a browser verifier, a downloadable Python bundle verifier, and a reproducible recall drill let reviewers inspect the evidence format. Successful hash or Merkle verification establishes artifact integrity; it does not establish source-data accuracy or regulatory approval. +A browser verifier, a downloadable Python bundle verifier, and a reproducible recall drill let reviewers inspect the evidence format. Successful hash or Merkle verification establishes artifact integrity; it does not establish source-data accuracy or regulatory approval. ## Capability and availability matrix diff --git a/docs/engineering/TRACEABILITY_FILE_INTAKE.md b/docs/engineering/TRACEABILITY_FILE_INTAKE.md index 922f80018..26db7d97c 100644 --- a/docs/engineering/TRACEABILITY_FILE_INTAKE.md +++ b/docs/engineering/TRACEABILITY_FILE_INTAKE.md @@ -93,8 +93,7 @@ tab does not pass `mode`. Validate-only calls `POST /api/v1/epcis/validate` once per JSON event and does not ingest. XML has no validate-only path; the tab tells the operator -to ingest the XML file directly. A sample document is linked from the tab -at `/samples/sample_epcis_2.0.json`. +to ingest the XML file directly. Empty documents, empty arrays, and non-JSON text fail in the browser. No request is sent. diff --git a/frontend/public/samples/regengine_sample_export.zip b/frontend/public/samples/regengine_sample_export.zip deleted file mode 100644 index 50b2936c86a2a15b6246ddf2b237f7cedb0e6a78..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6027 zcmb7IbyO7IwjN4BU}#VzB?Kg-bB0htLPDh*hGqsBy1PRVkdl;;Arc=wo3R4_=(Dvu0Kf+h z0D$k$tsBG^0=2b=Ld@V6&Mr<6Gl;hf%*|ckeQ76puVSdBX0Ei3vfjpx7E*?p?ysQiD^@10kXmz1oOr0(O#p zu*t%v%*m;dGgwAiLgM;~z^|ffF{YQm8~dO+X}UFUQT1F|vsk7)vK#MOnILey2)b^s z3*NN4eZY{oP}_pV+6i|Y{^+s0)-2nii?dmL1xy!}7|swO)uIG%%MtU$!@FS3bzX?N@nQ{mv$a(X?>M zH;mRa#|Qm1yFLwUhm8h1+h;Jdwu{4!g zK)?sHzF&&L$eQ?mHlHQASy^_OMUx0;o({Nxvo(#6I@=a@P|QsJtu5h8N|~_SE@rO* zdiTbU1C8vo4r4R!{z0Gb2k9T51o*%&FYVwknfc5grKqa(xWJ(`;s@T6$jiE8@i?`t z;f>|zy@y#F%V{HS3_zKK^EAOgWXXk{#qaN1^Y4F8VYa=?t0N+JH+*%}!$|VT>QdpE zc}9Z+G{`)@vi5)z#_qbE1p;zGHJ#rP<@Jm2#^WjPy1M!cZU|} zoX!ZEQ#S;JeH6QTz(piSyI%(lZUO^ltnHJp(O57v@CwU{4#pnU>|rbguRIS{h_2tD zDUBf|3?w3)-;r^2$$QOY21S~Do4{Cl%g+2B50zRfrB8SGCw_NSQ!wa2hxXISX?kc` zdKZP$Gr};8uZ~{46**^s!zXv#$|c>$-C(H#9Z>!`7NiJhVPv14#$beYM8TFpKrnv zqvH#&E%1fE>ZC)}44NV_O(iMFI9{mZ(U|E#(DJiflbAsN@U5tYqNV1EYQE=dyqWCT zNy+P6o~NQlNTb?dHO?`{PA4B0!2!CBw@#&R*^LUThm;d+cDb8N4B$dnYgSrQQuyT& zR z9J!?+nAI%DiVxg|G!+Ccj-#;AJ!*Hzm4RaOR4}VN!shg=s5=ExBt@qPIinXdY010z zc+TxxkoH=7K+5fi=zi66S&+i9#o1PNVwex<&@aiXce6Mh8~w#qGKg>(?N_DEM5LFuJET6RD&mCQt$@s1P$`d2=lRcD%EJLbc57+AR zQ})iz$*n15;cZ7DjC%O439kT5{`Ph)h-~x9dBd*4yV9w?6w=X~)#}!u(a}8{e9Rz` z`pr6$`Jtk|q#Jh*%f~CtMcK`zdj!v`DpX!%rr1LAwVaYHm>oPOw7)PG%!uXdUMp;l z46!%mK8#0@u)%uc!1RjBr19m`>Gc7%)(P zCfzW}j`!RZzi#;qx;U&&@{^b|Gos%r=J=K>gy7IOY=W;XX7Yz+-e`#$t*B{#QotFY zaqOx3Ww4rVdj%#UIC?&vhvO+3u z!BIbb<*7Pn5gH0a^wBvE!#Usv13upG&PW5atcD&YusNqC3pIhUKiPgcgh^^%&_tY6 zW;LMZ(GXeC*azO-7Od~$&C#NT-DMYQf(&+JXIdjubs8ynf(jC-Ud%ukoLlTypS3Qm znP3`z`@*VVlU`#EH~#(-Cn@hCm2p0(JTc+3?aPTC$sTLd-6M;lu_ucQ8IIT~Bvi2! z-R^hxernU2`-2g>fBxV|+w1D&6nSwm1mD#a!wwi;-`Fh zRZe?n*Kq@RP4#y+3Y$ON3}+_UEQZXm{I#9o5CF z3j27|#>7d#pIOK|hanlDSG;d>l0GS>QM-OpB@lnx@}v!WLoiRoFZ3h(mDYDExr+yh zYP_Yyz)j2oUCl^!r%q_)qa3`dHyLpB&TG-ReFdh`;&gcFigI*Ml(thTwe(dP&Drq8 zxXL6PO4?8`2sx+a>RtRIAb5ATS#yBnL?#p^C?2Qj{8BV(U-?tyOLK#;aL2`KOyWit zkxXJP@JF1Ym-v_wudnbD`j|NUnnNB1~6`o~*A*f;-8^sBofKudeXJ zpwJQE_g3i$&g=F9pUNvpZf>R56=Y$bOi9S(TkbMtAEwiII2#PQ-3FR{4l70Wbr^b< zd{p-#aa~mlljBVw1lBKeC>YB1sG>>9)8)U$oOTzTGk{+5)jk|0f9FWxhdUs6KFR($ zyv!nAY&t@xLpmEdW!%(A-W(iN#mNYvrg~{^^4cAHNNj9>0@#7|oEQ~Iwq$vY^_(K1 z$HJ}K(LUXhcpoU;cJlAq+tpI)v5Zbf-YGnP*;L;>!s)?4rB*$vXs6V+ z$;{OI`h=^1@9PY`TUxT;BOlCzc!9@u98t6S;jLOx+0TBkkP{fWS(M}P6JjYU;#Q)w zUhQya8hd;Uai#1a*(jlYBE}_FS}guBn;ED?Zrs^s?b>DV-jQc!hRZ?Yl5-Kn!PK7E zKhtueY~txy7ppANMX_WU)!9JqT1%7~GA?3}Xkt7>7`{V1gHP|Fc~I4HuYUXOTR9z# ztU){eL_(uAySaY0$6Vt*Pn~wD8);(U5X|mCGbv_>5%Vog_{!pgGjAbn7~j)`zpAiM z;!oV3n@ZQ7*gZ3sZP2irH|T!Ic2LA-6T*xCCZ5DY1o4$Bc}QCo)Y$ri`+S&1>3cum z378D_AOgjSFz|g!v&{oaqA&UdID@)yA1k)Dud{hcf?Be7B5goYmN$*&qg)GjKVGP^L zJcjJrDrK5AT>zeVuq9V!6$h1yo{>S z&bg^mwrlC{8`-j9*`b!Ud;K$C2d}VI7$xS;<38+(yxutREKVU03NXIC{IsJd^fA8X zJr8SrnWA-Fp76GH$CCg#lTkuL>bstH%|k$$VXkJSl0+%yR*hM?A{Ja5`xEF^FaQC= zk_XI0K^X$hV1E~x{68Y|ess?OK>z@xk^=x@e{IRE>@4h|W}Xl?dmDQz3wL`M^l#p* z^8Esop^qX0jin~O)Fo*~+9bK5j6k-ngFZ$jVB-L?x&{o7!Hwy=d7j&GWvLSP9JS4U^$N!@BoH zz1zR}h&p0vzS8wE9hTN?#EKZ5X1Kz~$9{kA)0DwlUW1s)@VZdf`I_SA-qJ%+ke~+e zauh5mNP|1bco6s)yg0voyh+814P#hI?rL6ClyR_>g znFFl`XIk(^tyx^u44-#Z_|9Szl9aJESCN}7nvWMTQET06zgjm>;sgV@BFJNHzGycY zFta&6kuM5YIX70X(C}AjdYqP1^GcP*=uKpGK~VE$bw1dvrGv!BH!tWxC z;s>A*zc2%-H8;=#46kBTl!BiE%|8esIVm@sKd99|YI-l!AKLT!S>4W^4G{@;XY)>H zJae^m-1mzXWRNOa-;b%iTm>bRADCHf359FqqdvJuwN$O^t`h3bK>%IC0J739x8P^{ z28#hq>*8N>&sy5e_XB+;c5S=222=~J??=Be78p^BH#H$8bz#j+F|@MMb0iBqr&NM2 zgjVMSELd)T@I)&M%=`0jc5(Y>yw-9SjPj+C(1lVlV=>bxXm3jtndo$)Mw6Oks3Wk zw96BAT({Lqn$k%9w4!z$$Kq{oF1;&W^)SDu3Ofl;rETOF`{gVHB{lGi_vH^+*n}q? zXnTryUIX=cXcH0XjH)HacN{D##AqK^ZoDq=cT`Vp{tjWm5Ko*dB=)l9b1E<*)2F54 zw8-hB@uul73$`3SsSjz1S%@hpTBU07NhalpOQe^ilM=rRmL$o6J4{!OBPT~qb~mqBt+Uktsyk3l(^wze%ew!3{Bh6;>YumpqPx4_tcQhToI#!Vk*cIiP(uIR5Zn(Fv3BJyU5%#oVyjW;IapPC(@TTJh*lQ=V_K z!()7gISnvRGrCKN=Zyf^F4mmmNzH4o**g?XDIe=aH53K}Ng`F<MFdP1#4g88NS zgjRKh*oGj~j@W}|wP*j9E@Hr!Chx{6w zDY@7`9Kk5EoK6(>M{^#y3WOim?UC9W6XAZl>l+hFLZie%@`0-IjT*u_wh8(*mMfc8 zg^D;wV}v+{$hAVN@q9YVF;V*COo1}H=rA|sU1PKoP@>s*n&)ye@k!p7L)bSxsc!3E z(!-QBlX3`o>sc$3#aGVME|)1xeC!W)#=qWw9U~MPoISS#e4#ZYb;+!7M9t~U-=I4> zVmmIsS#)=kbUPRIc|7`DnFFQi0pdfDm+nOn=(P=&+CE5hVGy1)6%_dO<_y!0^LP4x z_lN$MLiEo$Z~=ga_y7R!U(>&{1=QXK0(bu#=@-93LFb;5w{AY61^3~;K<>@QvInp_ z$4YD!D6(p;>sYcSK``ZuM9()*~h;Y@v9&PJVhco`E}zG@*vl zw)doTqkspZ{B6BrKGxu{H&JH8%!1G;&#lthcwmGfFYquK9Ko z>86OE`+erO{)WEc$DXc|Iz0+&!24$7)?bXK=(YoQskFJ#Odok@&5B%g3NI zZaAx5MC)=Yrj;Z1Gd(e!+AbQ2;7BuhwVL(a)=;sp9a7`M$Z&<3ri)*j!_Qg{T##Q@YI1nL`SO zCFq*p@OK{3nyMNe_^gn;o%Z1)I!A3+ZS(G!*k_Lv=jvPej4JfFP@l=wYu(bu-k2oQ zsImFV_*~;@so~&Kb_|CL| ({ ingestURL: vi.fn(), @@ -75,7 +87,7 @@ describe('EPCIS 2.0 import on /ingest', () => { mocks.storeApiKey.mockResolvedValue(undefined); }); - it('offers an EPCIS 2.0 tab with a JSON/XML chooser and the sample file link', async () => { + it('offers an EPCIS 2.0 tab with a JSON/XML chooser and no sample file link', async () => { await openEpcisTab(); expect(screen.getByText('CSV uploads skip bad rows. EPCIS files are all or nothing: if any event fails, nothing is saved.')).toBeInTheDocument(); expect(document.getElementById('epcis-file-upload')).toHaveAttribute( @@ -83,12 +95,11 @@ describe('EPCIS 2.0 import on /ingest', () => { '.json,application/json,.xml,application/xml,text/xml', ); expect(screen.getByText('JSON or XML files up to 5MB')).toBeInTheDocument(); - const sampleLink = screen.getByRole('link', { name: /download a sample epcis file/i }); - expect(sampleLink).toHaveAttribute('href', '/samples/sample_epcis_2.0.json'); + expect(screen.queryByRole('link', { name: /download a sample epcis file/i })).not.toBeInTheDocument(); }); - it('sends the sample EPCIS JSON document to the batch endpoint once with all events', async () => { - const eventList = (sampleEpcisDocument as { epcisBody: { eventList: unknown[] } }).epcisBody.eventList; + it('sends an EPCIS JSON document to the batch endpoint once with all events', async () => { + const eventList = (epcisDocument as { epcisBody: { eventList: unknown[] } }).epcisBody.eventList; expect(eventList.length).toBeGreaterThan(1); mocks.ingestEpcisBatch.mockResolvedValue({ total: eventList.length, @@ -100,7 +111,7 @@ describe('EPCIS 2.0 import on /ingest', () => { }); await openEpcisTab(); - uploadEpcisFile('sample_epcis_2.0.json', JSON.stringify(sampleEpcisDocument), 'application/json'); + uploadEpcisFile('events.json', JSON.stringify(epcisDocument), 'application/json'); await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); expect(mocks.ingestEpcisBatch).toHaveBeenCalledTimes(1); @@ -243,7 +254,7 @@ describe('EPCIS 2.0 import on /ingest', () => { mocks.ingestEpcisBatch.mockRejectedValue(new Error('Batch ingest failed')); await openEpcisTab(); - uploadEpcisFile('sample_epcis_2.0.json', JSON.stringify(sampleEpcisDocument), 'application/json'); + uploadEpcisFile('events.json', JSON.stringify(epcisDocument), 'application/json'); await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); diff --git a/frontend/src/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx index 83261482d..d18e238ee 100644 --- a/frontend/src/app/ingest/page.tsx +++ b/frontend/src/app/ingest/page.tsx @@ -712,13 +712,6 @@ export default function IngestPage() {

CSV uploads skip bad rows. EPCIS files are all or nothing: if any event fails, nothing is saved.

- diff --git a/qa/README.md b/qa/README.md index 8c32b359f..791ed0527 100644 --- a/qa/README.md +++ b/qa/README.md @@ -6,11 +6,6 @@ These checks **block merge**. No exceptions, no softening. | Rule | Check | Script | |---|---|---| -| Merkle chain integrity | Every hash cryptographically verified against its predecessor | `fsma-lite-check.js` | -| FSMA 204 CTE count | Must reference 7 CTEs, never 6 | `fsma-lite-check.js` | -| KDE completeness | All required fields present on every event | `fsma-lite-check.js` | -| Cross-format consistency | JSON, CSV, verification report, and manifest must agree on every hash | `full-flow.js` | -| Export artifact presence | All 5 files exist, valid format, correct record counts | `export-validate.js` | | No hardcoded credentials | No API keys, JWTs, or AWS keys in source | `frontend-security-scan.js` | | Bad data rejection | Invalid CTEs blocked, empty lot codes rejected | `bad-data.js` | @@ -20,9 +15,6 @@ These are the canonical "known bad" cases. If any of these ever passes, the pipe | Case | What breaks | Expected result | |---|---|---| -| Tamper any Merkle hash | Chain verification | `fsma-lite-check.js` exits 1, `full-flow.js` exits 1 | -| Change "7 CTEs" to "6 CTEs" in fsma-204/page.tsx | Regulatory copy check | `fsma-lite-check.js` exits 1 | -| Delete any sample artifact file | Export validation | `export-validate.js` exits 1 | | Add hardcoded API key to source | Credential scan | `frontend-security-scan.js` exits 1 | | Submit row with empty lot code | Bad data validator | `bad-data.js` exits 1 | @@ -49,11 +41,8 @@ decision → aggregates fast-gate + system-sim, blocks deploy on failu ## Running Locally ```bash -node qa/fsma-lite-check.js # 31 checks node qa/frontend-security-scan.js # 15 checks -node qa/full-flow.js # 68 checks node qa/bad-data.js # 18 checks -node qa/export-validate.js # 25 checks node qa/ai-analysis.js # 7 checks + warnings node qa/decision.js # requires FAST_GATE_RESULT and SYSTEM_SIM_RESULT env vars ``` diff --git a/qa/decision.js b/qa/decision.js index db1ecdd88..c77658a02 100644 --- a/qa/decision.js +++ b/qa/decision.js @@ -29,11 +29,8 @@ if (allPassed) { console.log('\u2705 DEPLOY: All checks passed.'); console.log(''); console.log(' Pipeline summary:'); - console.log(' \u2500 FSMA 204 compliance: verified'); console.log(' \u2500 Tenant isolation: verified'); - console.log(' \u2500 Full flow simulation: passed'); console.log(' \u2500 Bad data rejection: passed'); - console.log(' \u2500 Export artifacts: validated'); console.log(''); process.exit(0); } else if (anyFailed) { @@ -47,11 +44,8 @@ if (allPassed) { console.error(''); console.error(' Fix the failing checks before deploying.'); console.error(' Run individual check scripts locally:'); - console.error(' node qa/fsma-lite-check.js'); console.error(' node qa/frontend-security-scan.js'); - console.error(' node qa/full-flow.js'); console.error(' node qa/bad-data.js'); - console.error(' node qa/export-validate.js'); console.error(''); process.exit(1); } else if (anySkipped) { diff --git a/qa/export-validate.js b/qa/export-validate.js deleted file mode 100644 index cc66b7772..000000000 --- a/qa/export-validate.js +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env node -/** - * Export Artifact Validation - * Verifies all downloadable export files are: - * - Present and non-empty - * - Valid JSON/CSV - * - Cross-consistent (same hashes, same record counts) - * - Zip bundle contains all expected files - */ - -const fs = require('fs'); -const path = require('path'); - -const SAMPLES = path.join(__dirname, '..', 'frontend', 'public', 'samples'); -let passed = 0; -let failed = 0; - -function check(name, condition, detail) { - if (condition) { console.log(` \u2713 ${name}`); passed++; } - else { console.error(` \u2717 ${name}: ${detail}`); failed++; } -} - -console.log('\n=== Export Artifact Validation ===\n'); - -// ── 1. File Existence & Size ──────────────────────────── -console.log('1. File Presence'); - -const expectedFiles = [ - 'sample_epcis_2.0.json', - 'sample_fda_export.csv', - 'sample_chain_verification.json', - 'sample_manifest.json', - 'regengine_sample_export.zip', -]; - -for (const f of expectedFiles) { - const fp = path.join(SAMPLES, f); - const exists = fs.existsSync(fp); - check(`${f} exists`, exists, 'File not found'); - if (exists) { - const size = fs.statSync(fp).size; - check(`${f} is non-empty (${size} bytes)`, size > 100, `Only ${size} bytes`); - } -} - -// ── 2. JSON Validity ──────────────────────────────────── -console.log('\n2. JSON Validity'); - -const jsonFiles = ['sample_epcis_2.0.json', 'sample_chain_verification.json', 'sample_manifest.json']; -for (const f of jsonFiles) { - try { - JSON.parse(fs.readFileSync(path.join(SAMPLES, f), 'utf8')); - check(`${f} is valid JSON`, true, ''); - } catch (e) { - check(`${f} is valid JSON`, false, e.message); - } -} - -// ── 3. CSV Structure ──────────────────────────────────── -console.log('\n3. CSV Structure'); - -const csv = fs.readFileSync(path.join(SAMPLES, 'sample_fda_export.csv'), 'utf8'); -const csvLines = csv.trim().split('\n'); -check('CSV has header + 12 data rows', csvLines.length === 13, `Got ${csvLines.length} lines`); -check('CSV header has 16 columns', csvLines[0].split(',').length === 16, `Got ${csvLines[0].split(',').length}`); - -// ── 4. Record Count Consistency ───────────────────────── -console.log('\n4. Record Counts'); - -const epcis = JSON.parse(fs.readFileSync(path.join(SAMPLES, 'sample_epcis_2.0.json'), 'utf8')); -const verification = JSON.parse(fs.readFileSync(path.join(SAMPLES, 'sample_chain_verification.json'), 'utf8')); -const manifest = JSON.parse(fs.readFileSync(path.join(SAMPLES, 'sample_manifest.json'), 'utf8')); - -const counts = { - epcis: epcis.epcisBody.eventList.length, - csv: csvLines.length - 1, - verification: verification.verification_report.records.length, - integrityBlock: epcis['regengine:integrity'].chainLength, - manifest: manifest.manifest.integrity.chain_length, -}; - -check('All sources agree on 12 records', - Object.values(counts).every(c => c === 12), - `Counts: ${JSON.stringify(counts)}` -); - -// ── 5. Facility Consistency ───────────────────────────── -console.log('\n5. Facility Consistency'); - -const epcisGLNs = new Set(epcis.epcisBody.eventList.map(e => e.bizLocation.id.split(':').pop())); -const verFacilities = new Set(verification.verification_report.records.map(r => r.facility)); - -check('EPCIS has 7 unique GLNs', epcisGLNs.size === 7, `Got ${epcisGLNs.size}`); -check('Verification has 7 unique facilities', verFacilities.size === 7, `Got ${verFacilities.size}`); - -// ── 6. Manifest Integrity ─────────────────────────────── -console.log('\n6. Manifest'); - -check('Manifest lists 3 files', manifest.manifest.files.length === 3, `Got ${manifest.manifest.files.length}`); -check('Manifest has sha256', typeof manifest.manifest.manifest_sha256 === 'string' && manifest.manifest.manifest_sha256.length === 64, 'Missing or invalid'); -check('Manifest chain_valid is true', manifest.manifest.integrity.chain_valid === true, 'Not valid'); -check('Manifest has retention notice', manifest.manifest.retention_notice?.length > 20, 'Missing'); - -// ── 7. Zip Bundle ─────────────────────────────────────── -console.log('\n7. Zip Bundle'); - -const zipPath = path.join(SAMPLES, 'regengine_sample_export.zip'); -if (fs.existsSync(zipPath)) { - const zipSize = fs.statSync(zipPath).size; - check('Zip is > 1KB', zipSize > 1000, `Only ${zipSize} bytes`); - check('Zip is < 1MB (reasonable size)', zipSize < 1000000, `${zipSize} bytes is too large`); - // Check zip magic bytes - const buf = Buffer.alloc(4); - const fd = fs.openSync(zipPath, 'r'); - fs.readSync(fd, buf, 0, 4, 0); - fs.closeSync(fd); - check('Zip has valid magic bytes (PK)', buf[0] === 0x50 && buf[1] === 0x4B, 'Not a valid zip file'); -} - -// ── Summary ───────────────────────────────────────────── -console.log(`\n${'='.repeat(50)}`); -console.log(`Export Validation: ${passed} passed, ${failed} failed`); -console.log(`${'='.repeat(50)}\n`); - -if (failed > 0) process.exit(1); diff --git a/qa/fsma-lite-check.js b/qa/fsma-lite-check.js deleted file mode 100644 index 9bb0c9211..000000000 --- a/qa/fsma-lite-check.js +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env node -/** - * FSMA 204 Compliance Lite Check - * Validates sample export data against FDA FSMA 204 requirements: - * - 7 CTE types recognized - * - Required KDEs present per event - * - Lot codes follow TLC format - * - Hash chain integrity - * - Export format compliance (EPCIS 2.0) - */ - -const fs = require('fs'); -const crypto = require('crypto'); -const path = require('path'); - -const SAMPLES = path.join(__dirname, '..', 'frontend', 'public', 'samples'); - -const VALID_CTES = [ - 'harvesting', 'cooling', 'initial_packing', - 'first_land_based_receiving', - 'shipping', 'receiving', 'transformation' -]; - -const REQUIRED_EXTENSION_FIELDS = [ - 'fsma:traceabilityLotCode', - 'fsma:productDescription', - 'fsma:quantity', - 'fsma:unitOfMeasure', - 'regengine:payloadSHA256', - 'regengine:merkleHash', - 'regengine:sequenceNumber' -]; - -let passed = 0; -let failed = 0; - -function check(name, condition, detail) { - if (condition) { - console.log(` \u2713 ${name}`); - passed++; - } else { - console.error(` \u2717 ${name}: ${detail}`); - failed++; - } -} - -// ── 1. Load sample EPCIS ──────────────────────────────── -console.log('\n=== FSMA 204 Compliance Checks ===\n'); -console.log('1. EPCIS 2.0 Structure'); - -const epcisPath = path.join(SAMPLES, 'sample_epcis_2.0.json'); -check('EPCIS file exists', fs.existsSync(epcisPath), 'Missing sample_epcis_2.0.json'); - -const epcis = JSON.parse(fs.readFileSync(epcisPath, 'utf8')); -check('Has @context', Array.isArray(epcis['@context']), 'Missing @context array'); -check('Schema version is 2.0', epcis.schemaVersion === '2.0', `Got ${epcis.schemaVersion}`); -check('Type is EPCISDocument', epcis.type === 'EPCISDocument', `Got ${epcis.type}`); -check('Has eventList', Array.isArray(epcis.epcisBody?.eventList), 'Missing epcisBody.eventList'); - -const events = epcis.epcisBody.eventList; - -// ── 2. CTE Validation ─────────────────────────────────── -console.log('\n2. Critical Tracking Events'); - -const cteTypes = events.map(e => e.bizStep.replace('urn:epcglobal:cbv:bizstep:', '')); -const uniqueCtes = [...new Set(cteTypes)]; - -check('All events use valid CTE types', - cteTypes.every(c => VALID_CTES.includes(c)), - `Invalid CTEs: ${cteTypes.filter(c => !VALID_CTES.includes(c)).join(', ')}` -); - -check('At least 3 distinct CTE types used', - uniqueCtes.length >= 3, - `Only ${uniqueCtes.length} types: ${uniqueCtes.join(', ')}` -); - -// ── 3. KDE Completeness ───────────────────────────────── -console.log('\n3. Key Data Elements'); - -events.forEach((evt, i) => { - const ext = evt.extension || {}; - const missing = REQUIRED_EXTENSION_FIELDS.filter(f => !(f in ext)); - check(`Event ${i + 1} has all required KDEs`, - missing.length === 0, - `Missing: ${missing.join(', ')}` - ); -}); - -// ── 4. Lot Code Format ────────────────────────────────── -console.log('\n4. Traceability Lot Codes'); - -const lotCodes = events.map(e => e.extension['fsma:traceabilityLotCode']); -check('All events have a TLC', lotCodes.every(Boolean), 'Some events missing TLC'); -check('TLC is consistent across chain', - new Set(lotCodes).size === 1, - `Multiple TLCs: ${[...new Set(lotCodes)].join(', ')}` -); -check('TLC is non-empty string', - lotCodes[0] && lotCodes[0].length > 3, - `TLC too short: "${lotCodes[0]}"` -); - -// ── 5. Hash Chain Integrity ───────────────────────────── -console.log('\n5. Merkle Hash Chain'); - -const hashes = events.map(e => ({ - payload: e.extension['regengine:payloadSHA256'], - merkle: e.extension['regengine:merkleHash'], - seq: e.extension['regengine:sequenceNumber'] -})); - -check('First event: merkle == payload (chain root)', - hashes[0].merkle === hashes[0].payload, - `Root mismatch: ${hashes[0].merkle.slice(0, 16)} != ${hashes[0].payload.slice(0, 16)}` -); - -check('All hashes are 64-char hex strings', - hashes.every(h => /^[0-9a-f]{64}$/.test(h.payload) && /^[0-9a-f]{64}$/.test(h.merkle)), - 'Some hashes are not valid SHA-256' -); - -// Verify Merkle chain linkage -let chainValid = true; -for (let i = 1; i < hashes.length; i++) { - const expected = crypto.createHash('sha256') - .update(hashes[i - 1].merkle + hashes[i].payload) - .digest('hex'); - if (expected !== hashes[i].merkle) { - chainValid = false; - console.error(` Chain break at seq ${i + 1}: expected ${expected.slice(0, 16)}..., got ${hashes[i].merkle.slice(0, 16)}...`); - } -} -check('Merkle chain is cryptographically valid', chainValid, 'Chain verification failed'); - -// Verify final hash matches integrity block -const finalHash = epcis['regengine:integrity']?.finalMerkleHash; -check('Final hash matches integrity block', - finalHash === hashes[hashes.length - 1].merkle, - `Integrity: ${finalHash?.slice(0, 16)} vs chain: ${hashes[hashes.length - 1].merkle.slice(0, 16)}` -); - -check('Chain length matches event count', - epcis['regengine:integrity']?.chainLength === events.length, - `Integrity says ${epcis['regengine:integrity']?.chainLength}, events: ${events.length}` -); - -// ── 6. Sequence Ordering ──────────────────────────────── -console.log('\n6. Event Sequencing'); - -const seqs = events.map(e => e.extension['regengine:sequenceNumber']); -check('Sequences start at 1', seqs[0] === 1, `Starts at ${seqs[0]}`); -check('Sequences are contiguous', - seqs.every((s, i) => s === i + 1), - `Gap in sequence: ${seqs.join(',')}` -); - -// ── 7. Site Copy Check ────────────────────────────────── -console.log('\n7. Regulatory Copy Consistency'); - -const fsmaPage = path.join(__dirname, '..', 'frontend', 'src', 'app', 'fsma-204', 'page.tsx'); -if (fs.existsSync(fsmaPage)) { - const content = fs.readFileSync(fsmaPage, 'utf8'); - check('FSMA guide does NOT say "6 Critical Tracking Events"', - !content.includes('6 Critical Tracking Events'), - 'Found "6 Critical Tracking Events" — must be 7' - ); - check('FSMA guide says "7 Critical Tracking Events"', - content.includes('7 Critical Tracking Events'), - 'Missing "7 Critical Tracking Events"' - ); -} else { - console.log(' (skipped — fsma-204/page.tsx not found)'); -} - -// ── Summary ───────────────────────────────────────────── -console.log(`\n${'='.repeat(50)}`); -console.log(`FSMA 204 Checks: ${passed} passed, ${failed} failed`); -console.log(`${'='.repeat(50)}\n`); - -if (failed > 0) { - process.exit(1); -} diff --git a/qa/full-flow.js b/qa/full-flow.js deleted file mode 100644 index d3a18ca38..000000000 --- a/qa/full-flow.js +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node -/** - * Full Flow Simulation - * Simulates the ingestion-to-export pipeline offline using sample data: - * 1. Parse CSV input - * 2. Validate fields - * 3. Compute SHA-256 hashes - * 4. Build Merkle chain - * 5. Generate EPCIS 2.0 output - * 6. Verify output matches expected - */ - -const fs = require('fs'); -const crypto = require('crypto'); -const path = require('path'); - -const SAMPLES = path.join(__dirname, '..', 'frontend', 'public', 'samples'); -let passed = 0; -let failed = 0; - -function check(name, condition, detail) { - if (condition) { console.log(` \u2713 ${name}`); passed++; } - else { console.error(` \u2717 ${name}: ${detail}`); failed++; } -} - -console.log('\n=== Full Flow Simulation ===\n'); - -// ── Step 1: Parse CSV ─────────────────────────────────── -console.log('1. CSV Ingestion'); - -const csvPath = path.join(SAMPLES, 'sample_fda_export.csv'); -const csvContent = fs.readFileSync(csvPath, 'utf8'); -const lines = csvContent.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); -const headers = lines[0].split(',').map(h => h.trim()); -const rows = lines.slice(1).map(line => { - const values = []; - let current = ''; - let inQuotes = false; - for (const char of line) { - if (char === '"') { inQuotes = !inQuotes; } - else if (char === ',' && !inQuotes) { values.push(current); current = ''; } - else { current += char; } - } - values.push(current); - const row = {}; - headers.forEach((h, i) => row[h] = values[i] || ''); - return row; -}); - -check('CSV has header row', headers.length >= 10, `Only ${headers.length} columns`); -check('CSV has 12 data rows', rows.length === 12, `Got ${rows.length} rows`); -check('CSV has Merkle Hash column', headers.includes('Merkle Hash'), 'Missing Merkle Hash'); -check('CSV has Sequence # column', headers.includes('Sequence #'), 'Missing Sequence #'); - -// ── Step 2: Validate Fields ───────────────────────────── -console.log('\n2. Field Validation'); - -rows.forEach((row, i) => { - const seq = parseInt(row['Sequence #']); - check(`Row ${seq}: has lot code`, row['Traceability Lot Code']?.length > 0, 'Empty TLC'); - check(`Row ${seq}: has facility`, row['Facility Name']?.length > 0, 'Empty facility'); - check(`Row ${seq}: has valid CTE`, row['CTE Type']?.length > 0, 'Empty CTE type'); - check(`Row ${seq}: has quantity`, parseInt(row['Quantity']) > 0, `Qty: ${row['Quantity']}`); -}); - -// ── Step 3: Verify Hashes ─────────────────────────────── -console.log('\n3. Hash Verification'); - -const csvHashes = rows.map(r => ({ - payload: r['Payload SHA-256'], - merkle: r['Merkle Hash'], - seq: parseInt(r['Sequence #']) -})); - -check('First row: merkle == payload (root)', - csvHashes[0].merkle === csvHashes[0].payload, - 'Root hash mismatch' -); - -let csvChainValid = true; -for (let i = 1; i < csvHashes.length; i++) { - const expected = crypto.createHash('sha256') - .update(csvHashes[i - 1].merkle + csvHashes[i].payload) - .digest('hex'); - if (expected !== csvHashes[i].merkle) { - csvChainValid = false; - console.error(` Break at seq ${csvHashes[i].seq}`); - } -} -check('CSV Merkle chain is valid', csvChainValid, 'Chain broken'); - -// ── Step 4: Cross-validate CSV ↔ JSON ─────────────────── -console.log('\n4. Cross-Format Validation'); - -const epcis = JSON.parse(fs.readFileSync(path.join(SAMPLES, 'sample_epcis_2.0.json'), 'utf8')); -const jsonEvents = epcis.epcisBody.eventList; - -check('JSON and CSV have same event count', - jsonEvents.length === rows.length, - `JSON: ${jsonEvents.length}, CSV: ${rows.length}` -); - -jsonEvents.forEach((evt, i) => { - const csvRow = rows[i]; - const jsonMerkle = evt.extension['regengine:merkleHash']; - const csvMerkle = csvRow['Merkle Hash']; - check(`Seq ${i + 1}: JSON merkle == CSV merkle`, - jsonMerkle === csvMerkle, - `JSON: ${jsonMerkle.slice(0, 16)} vs CSV: ${csvMerkle.slice(0, 16)}` - ); -}); - -// ── Step 5: Verify Final Hash Propagation ──────────────── -console.log('\n5. Final Hash Propagation'); - -const verification = JSON.parse( - fs.readFileSync(path.join(SAMPLES, 'sample_chain_verification.json'), 'utf8') -); -const manifest = JSON.parse( - fs.readFileSync(path.join(SAMPLES, 'sample_manifest.json'), 'utf8') -); - -const finalHashes = { - epcis: epcis['regengine:integrity']?.finalMerkleHash, - verification: verification.verification_report?.final_merkle_hash, - manifest: manifest.manifest?.integrity?.final_merkle_hash, - csvLast: csvHashes[csvHashes.length - 1].merkle, -}; - -check('All 4 artifacts agree on final hash', - new Set(Object.values(finalHashes)).size === 1, - `Mismatch: ${JSON.stringify(finalHashes, null, 2)}` -); - -// ── Summary ───────────────────────────────────────────── -console.log(`\n${'='.repeat(50)}`); -console.log(`Full Flow: ${passed} passed, ${failed} failed`); -console.log(`${'='.repeat(50)}\n`); - -if (failed > 0) process.exit(1); diff --git a/qa/summary.js b/qa/summary.js index 4dd2e39ee..1b7065738 100644 --- a/qa/summary.js +++ b/qa/summary.js @@ -10,11 +10,8 @@ const fs = require('fs'); const path = require('path'); const SCRIPTS = [ - { name: 'compliance', script: 'fsma-lite-check.js', category: 'compliance' }, { name: 'frontend-security', script: 'frontend-security-scan.js', category: 'security' }, - { name: 'full-flow', script: 'full-flow.js', category: 'pipeline' }, { name: 'bad-data', script: 'bad-data.js', category: 'pipeline' }, - { name: 'export', script: 'export-validate.js', category: 'pipeline' }, { name: 'ai-analysis', script: 'ai-analysis.js', category: 'trust' }, ]; From dd871b5356202c28b1c98e8fd830f1692f14ac8e Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Fri, 23 Oct 2026 22:28:03 -0700 Subject: [PATCH 2/8] test: use data-free EPCIS placeholder in ingest-epcis test Signed-off-by: Christopher Sellers <1@christophersellers.com> --- frontend/src/__tests__/pages/ingest-epcis.test.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index 2415dd443..e65658eff 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -3,18 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import IngestPage from '@/app/ingest/page'; -// Minimal EPCIS 2.0 request body for exercising the upload path in tests. -const epcisDocument = { - '@context': ['https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld'], - type: 'EPCISDocument', - schemaVersion: '2.0', - epcisBody: { - eventList: [ - { type: 'ObjectEvent', action: 'OBSERVE', bizStep: 'shipping', eventTime: '2026-01-01T00:00:00Z', eventTimeZoneOffset: '+00:00' }, - { type: 'ObjectEvent', action: 'OBSERVE', bizStep: 'receiving', eventTime: '2026-01-02T00:00:00Z', eventTimeZoneOffset: '+00:00' }, - ], - }, -}; +// Data-free placeholder: the tests only check passthrough and event count. +const epcisDocument = { epcisBody: { eventList: Array.from({ length: 2 }, () => ({})) } }; const mocks = vi.hoisted(() => ({ ingestURL: vi.fn(), From e89f3f089a063b30e3ec4f7c6305d64620c9df52 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:34:49 -0700 Subject: [PATCH 3/8] test(ingest-epcis): use real backend EPCIS error and derive counts from event lists Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../DEPLOY_CHECKLIST_SECURITY_HARDENING.md | 3 -- .../src/__tests__/pages/ingest-epcis.test.tsx | 50 +++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/archive/DEPLOY_CHECKLIST_SECURITY_HARDENING.md b/docs/archive/DEPLOY_CHECKLIST_SECURITY_HARDENING.md index 841d67dc1..1f8be5d6d 100644 --- a/docs/archive/DEPLOY_CHECKLIST_SECURITY_HARDENING.md +++ b/docs/archive/DEPLOY_CHECKLIST_SECURITY_HARDENING.md @@ -270,13 +270,10 @@ kubectl rollout status deployment/ -n regengine - [ ] **5.1** Run full QA pipeline against production ```bash # Stage 1: Fast Gate - node qa/fsma-lite-check.js --env=production node qa/tenant-test.js --env=production # Stage 2: System Simulation - node qa/full-flow.js --env=production node qa/bad-data.js --env=production - node qa/export-validate.js --env=production # Stage 3: AI Analysis node qa/ai-analysis.js --env=production diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index b6b47cbb2..d2d9b2f6e 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -43,6 +43,13 @@ vi.mock('@/app/ingest/NormalizedDocumentViewer', () => ({ // array vs .xml), so no event field values are needed. Real data only in tests. const SINGLE_EVENT: Record = {}; +// Real backend output for the placeholder above: an event with no `type` +// gets this message from _validate_epcis (services/ingestion/app/epcis/ +// validation.py), and the batch path wraps it as HTTPException(400, +// detail={"errors": [...]}) per event (persistence.py). +const BACKEND_EVENT_ERROR = 'Unsupported EPCIS event type'; +const BACKEND_VALIDATION_STATUS = 400; + const EPCIS_XML = ` @@ -112,12 +119,16 @@ describe('EPCIS 2.0 import on /ingest', () => { it('sends a bare JSON array of events to the batch endpoint', async () => { const events = [{ ...SINGLE_EVENT }, { ...SINGLE_EVENT }]; + const failedIndex = events.length - 1; + const created = events.length - 1; + const failed = events.length - created; + const detail = { errors: [BACKEND_EVENT_ERROR] }; mocks.ingestEpcisBatch.mockResolvedValue({ - total: 2, - created: 1, - failed: 1, - results: [{ index: 1, status_code: 400, detail: 'Missing bizLocation' }], - errors: [{ index: 1, detail: 'Missing bizLocation' }], + total: events.length, + created, + failed, + results: [{ index: failedIndex, status_code: BACKEND_VALIDATION_STATUS, detail }], + errors: [{ index: failedIndex, detail }], mode: 'partial', }); @@ -131,8 +142,10 @@ describe('EPCIS 2.0 import on /ingest', () => { events, }); expect(await screen.findByText('EPCIS partially accepted')).toBeInTheDocument(); - expect(screen.getByText('1 of 2 events accepted; 1 rejected.')).toBeInTheDocument(); - expect(screen.getByText('Event 2: Missing bizLocation')).toBeInTheDocument(); + expect( + screen.getByText(`${created} of ${events.length} events accepted; ${failed} rejected.`), + ).toBeInTheDocument(); + expect(screen.getByText(`Event ${failedIndex + 1}: ${BACKEND_EVENT_ERROR}`)).toBeInTheDocument(); }); it('sends a single-event JSON to the single-event endpoint', async () => { @@ -150,13 +163,19 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(await screen.findByText('EPCIS processed')).toBeInTheDocument(); - expect(screen.getByText('1 of 1 events accepted; 0 rejected.')).toBeInTheDocument(); + const singleTotal = [SINGLE_EVENT].length; + expect( + screen.getByText(`${singleTotal} of ${singleTotal} events accepted; 0 rejected.`), + ).toBeInTheDocument(); }); it('sends an XML file to the XML endpoint untouched', async () => { + // EPCIS_XML carries an empty EventList; the mocked parse reports one + // event created so the success path renders. Counts derive from it. + const xmlTotal = [EPCIS_XML].length; mocks.ingestEpcisXml.mockResolvedValue({ - total: 1, - created: 1, + total: xmlTotal, + created: xmlTotal, failed: 0, results: [], errors: [], @@ -195,14 +214,17 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.ingestFile).not.toHaveBeenCalled(); expect(await screen.findByText('All events valid')).toBeInTheDocument(); - expect(screen.getByText('1 of 1 events valid.')).toBeInTheDocument(); + const validTotal = [SINGLE_EVENT].length; + expect(screen.getByText(`${validTotal} of ${validTotal} events valid.`)).toBeInTheDocument(); }); it('validate-only reports per-event failures for a batch document', async () => { const events = [{ ...SINGLE_EVENT }, { ...SINGLE_EVENT }]; mocks.validateEpcis .mockResolvedValueOnce({ valid: true, errors: [], normalized_cte: null }) - .mockResolvedValueOnce({ valid: false, errors: ['Missing bizLocation'], normalized_cte: null }); + .mockResolvedValueOnce({ valid: false, errors: [BACKEND_EVENT_ERROR], normalized_cte: null }); + const failedIndex = events.length - 1; + const validCount = events.length - 1; await openEpcisTab(); uploadEpcisFile('events.json', JSON.stringify(events), 'application/json'); @@ -213,8 +235,8 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(await screen.findByText('Validation found issues')).toBeInTheDocument(); - expect(screen.getByText('1 of 2 events valid.')).toBeInTheDocument(); - expect(screen.getByText('Event 2: Missing bizLocation')).toBeInTheDocument(); + expect(screen.getByText(`${validCount} of ${events.length} events valid.`)).toBeInTheDocument(); + expect(screen.getByText(`Event ${failedIndex + 1}: ${BACKEND_EVENT_ERROR}`)).toBeInTheDocument(); }); it('shows a parse error and makes no request for a non-JSON .json file', async () => { From 10fd5ffc8f3fb685e780c93c42230f6ab1c0ea08 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:37:48 -0700 Subject: [PATCH 4/8] test(ingest-epcis): mock the real backend response for empty placeholder events Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../src/__tests__/pages/ingest-epcis.test.tsx | 119 ++++++++++-------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index d2d9b2f6e..c9fe53a53 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -43,12 +43,39 @@ vi.mock('@/app/ingest/NormalizedDocumentViewer', () => ({ // array vs .xml), so no event field values are needed. Real data only in tests. const SINGLE_EVENT: Record = {}; -// Real backend output for the placeholder above: an event with no `type` -// gets this message from _validate_epcis (services/ingestion/app/epcis/ -// validation.py), and the batch path wraps it as HTTPException(400, -// detail={"errors": [...]}) per event (persistence.py). -const BACKEND_EVENT_ERROR = 'Unsupported EPCIS event type'; +// What _validate_epcis (services/ingestion/app/epcis/validation.py) really +// returns for the {} placeholder, in order: each missing required field +// (type, eventTime, action, bizStep), then the unsupported type, then the +// missing lot code. Every {} event is invalid, so the batch path rejects each +// one with HTTPException(400, detail={"errors": [...]}) (persistence.py). +const REQUIRED_FIELDS = ['type', 'eventTime', 'action', 'bizStep']; +const BACKEND_EVENT_ERRORS = [ + ...REQUIRED_FIELDS.map((field) => `Missing required EPCIS field '${field}'`), + 'Unsupported EPCIS event type', + 'Missing traceability lot code (fsma:traceabilityLotCode or cbvmda:lotNumber)', +]; const BACKEND_VALIDATION_STATUS = 400; +// page.tsx and epcis-upload.ts both join an error list with ': '. +const JOINED_EVENT_ERRORS = BACKEND_EVENT_ERRORS.join(': '); + +// The real batch response for a list of {} events: none created, all failed. +function rejectedBatchResponse(events: unknown[]) { + const detail = { errors: BACKEND_EVENT_ERRORS }; + return { + total: events.length, + created: 0, + failed: events.length, + results: events.map((_, index) => ({ index, status_code: BACKEND_VALIDATION_STATUS, detail })), + errors: events.map((_, index) => ({ index, detail })), + mode: 'partial', + }; +} + +function expectEveryEventRejected(count: number) { + for (let index = 0; index < count; index += 1) { + expect(screen.getByText(`Event ${index + 1}: ${JOINED_EVENT_ERRORS}`)).toBeInTheDocument(); + } +} const EPCIS_XML = ` @@ -89,14 +116,7 @@ describe('EPCIS 2.0 import on /ingest', () => { it('sends an EPCIS JSON document to the batch endpoint once with all events', async () => { const eventList = (epcisDocument as { epcisBody: { eventList: unknown[] } }).epcisBody.eventList; expect(eventList.length).toBeGreaterThan(1); - mocks.ingestEpcisBatch.mockResolvedValue({ - total: eventList.length, - created: eventList.length, - failed: 0, - results: [], - errors: [], - mode: 'partial', - }); + mocks.ingestEpcisBatch.mockResolvedValue(rejectedBatchResponse(eventList)); await openEpcisTab(); uploadEpcisFile('events.json', JSON.stringify(epcisDocument), 'application/json'); @@ -110,27 +130,17 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.validateEpcis).not.toHaveBeenCalled(); - expect(await screen.findByText('EPCIS processed')).toBeInTheDocument(); + expect(await screen.findByText('No events accepted')).toBeInTheDocument(); expect( - screen.getByText(`${eventList.length} of ${eventList.length} events accepted; 0 rejected.`), + screen.getByText(`0 of ${eventList.length} events accepted; ${eventList.length} rejected.`), ).toBeInTheDocument(); - expect(mocks.refresh).toHaveBeenCalledTimes(1); + expectEveryEventRejected(eventList.length); + expect(mocks.refresh).not.toHaveBeenCalled(); }); it('sends a bare JSON array of events to the batch endpoint', async () => { const events = [{ ...SINGLE_EVENT }, { ...SINGLE_EVENT }]; - const failedIndex = events.length - 1; - const created = events.length - 1; - const failed = events.length - created; - const detail = { errors: [BACKEND_EVENT_ERROR] }; - mocks.ingestEpcisBatch.mockResolvedValue({ - total: events.length, - created, - failed, - results: [{ index: failedIndex, status_code: BACKEND_VALIDATION_STATUS, detail }], - errors: [{ index: failedIndex, detail }], - mode: 'partial', - }); + mocks.ingestEpcisBatch.mockResolvedValue(rejectedBatchResponse(events)); await openEpcisTab(); uploadEpcisFile('events.json', JSON.stringify(events), 'application/json'); @@ -141,15 +151,19 @@ describe('EPCIS 2.0 import on /ingest', () => { apiKey: 'cookie-managed', // pragma: allowlist secret events, }); - expect(await screen.findByText('EPCIS partially accepted')).toBeInTheDocument(); + expect(await screen.findByText('No events accepted')).toBeInTheDocument(); expect( - screen.getByText(`${created} of ${events.length} events accepted; ${failed} rejected.`), + screen.getByText(`0 of ${events.length} events accepted; ${events.length} rejected.`), ).toBeInTheDocument(); - expect(screen.getByText(`Event ${failedIndex + 1}: ${BACKEND_EVENT_ERROR}`)).toBeInTheDocument(); + expectEveryEventRejected(events.length); }); it('sends a single-event JSON to the single-event endpoint', async () => { - mocks.ingestEpcisEvent.mockResolvedValue({ status: 201 }); + mocks.ingestEpcisEvent.mockRejectedValue( + Object.assign(new Error(`Request failed with status code ${BACKEND_VALIDATION_STATUS}`), { + response: { status: BACKEND_VALIDATION_STATUS, data: { detail: { errors: BACKEND_EVENT_ERRORS } } }, + }), + ); await openEpcisTab(); uploadEpcisFile('event.json', JSON.stringify(SINGLE_EVENT), 'application/json'); @@ -162,20 +176,16 @@ describe('EPCIS 2.0 import on /ingest', () => { }); expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); - expect(await screen.findByText('EPCIS processed')).toBeInTheDocument(); - const singleTotal = [SINGLE_EVENT].length; - expect( - screen.getByText(`${singleTotal} of ${singleTotal} events accepted; 0 rejected.`), - ).toBeInTheDocument(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(JOINED_EVENT_ERRORS)).toBeInTheDocument(); + expect(mocks.refresh).not.toHaveBeenCalled(); }); it('sends an XML file to the XML endpoint untouched', async () => { - // EPCIS_XML carries an empty EventList; the mocked parse reports one - // event created so the success path renders. Counts derive from it. - const xmlTotal = [EPCIS_XML].length; + // EPCIS_XML carries an empty EventList, so the backend parses no events. mocks.ingestEpcisXml.mockResolvedValue({ - total: xmlTotal, - created: xmlTotal, + total: 0, + created: 0, failed: 0, results: [], errors: [], @@ -194,11 +204,14 @@ describe('EPCIS 2.0 import on /ingest', () => { }); expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); - expect(await screen.findByText('EPCIS processed')).toBeInTheDocument(); + // The page passes the response through unchanged. + expect(await screen.findByText('No events accepted')).toBeInTheDocument(); + expect(screen.getByText('0 of 0 events accepted; 0 rejected.')).toBeInTheDocument(); + expect(mocks.refresh).not.toHaveBeenCalled(); }); it('validate-only calls the validate endpoint and never ingests', async () => { - mocks.validateEpcis.mockResolvedValue({ valid: true, errors: [], normalized_cte: null }); + mocks.validateEpcis.mockResolvedValue({ valid: false, errors: BACKEND_EVENT_ERRORS, normalized_cte: null }); await openEpcisTab(); uploadEpcisFile('event.json', JSON.stringify(SINGLE_EVENT), 'application/json'); @@ -213,30 +226,26 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.ingestFile).not.toHaveBeenCalled(); - expect(await screen.findByText('All events valid')).toBeInTheDocument(); - const validTotal = [SINGLE_EVENT].length; - expect(screen.getByText(`${validTotal} of ${validTotal} events valid.`)).toBeInTheDocument(); + expect(await screen.findByText('Validation found issues')).toBeInTheDocument(); + expect(screen.getByText(`0 of ${[SINGLE_EVENT].length} events valid.`)).toBeInTheDocument(); + expectEveryEventRejected([SINGLE_EVENT].length); }); it('validate-only reports per-event failures for a batch document', async () => { const events = [{ ...SINGLE_EVENT }, { ...SINGLE_EVENT }]; - mocks.validateEpcis - .mockResolvedValueOnce({ valid: true, errors: [], normalized_cte: null }) - .mockResolvedValueOnce({ valid: false, errors: [BACKEND_EVENT_ERROR], normalized_cte: null }); - const failedIndex = events.length - 1; - const validCount = events.length - 1; + mocks.validateEpcis.mockResolvedValue({ valid: false, errors: BACKEND_EVENT_ERRORS, normalized_cte: null }); await openEpcisTab(); uploadEpcisFile('events.json', JSON.stringify(events), 'application/json'); await userEvent.click(screen.getByRole('button', { name: /^validate only$/i })); - expect(mocks.validateEpcis).toHaveBeenCalledTimes(2); + expect(mocks.validateEpcis).toHaveBeenCalledTimes(events.length); expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(await screen.findByText('Validation found issues')).toBeInTheDocument(); - expect(screen.getByText(`${validCount} of ${events.length} events valid.`)).toBeInTheDocument(); - expect(screen.getByText(`Event ${failedIndex + 1}: ${BACKEND_EVENT_ERROR}`)).toBeInTheDocument(); + expect(screen.getByText(`0 of ${events.length} events valid.`)).toBeInTheDocument(); + expectEveryEventRejected(events.length); }); it('shows a parse error and makes no request for a non-JSON .json file', async () => { From 82323b8f72178dd1813d594f899b523c47d65e68 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:40:52 -0700 Subject: [PATCH 5/8] test(ingest-epcis): mock real 400/422 rejections for batch modes and empty XML Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../src/__tests__/pages/ingest-epcis.test.tsx | 95 ++++++++++++------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index c9fe53a53..04750adbb 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -58,17 +58,49 @@ const BACKEND_VALIDATION_STATUS = 400; // page.tsx and epcis-upload.ts both join an error list with ': '. const JOINED_EVENT_ERRORS = BACKEND_EVENT_ERRORS.join(': '); -// The real batch response for a list of {} events: none created, all failed. -function rejectedBatchResponse(events: unknown[]) { - const detail = { errors: BACKEND_EVENT_ERRORS }; - return { +// Axios-style rejection, as the api client surfaces any non-2xx response. +function axiosRejection(status: number, data: unknown) { + return Object.assign(new Error(`Request failed with status code ${status}`), { + response: { status, data }, + }); +} + +// An all-failed batch is a 400 rejection in both modes (router.py +// create_epcis_events_batch). The frontend sends no ?mode, so the deployment +// default applies: atomic, per _batch_transactional() in persistence.py. +// +// Atomic: _ingest_batch_events_db_atomic raises HTTPException(400) with this +// detail; each error keeps its index, status_code and detail. +const ATOMIC_BATCH_MESSAGE = + 'One or more events failed pre-DB validation. No events were persisted. ' + + 'Use ?mode=partial to opt in to per-event processing with HTTP 207 semantics.'; +function atomicBatchRejection(events: unknown[]) { + return axiosRejection(BACKEND_VALIDATION_STATUS, { + detail: { + error: 'batch_validation_failed', + mode: 'atomic', + message: ATOMIC_BATCH_MESSAGE, + errors: events.map((_, index) => ({ + index, + status_code: BACKEND_VALIDATION_STATUS, + detail: { errors: BACKEND_EVENT_ERRORS }, + })), + }, + }); +} + +// Partial: the router returns the payload itself (no detail wrapper) with +// status 400. results only holds successes, so it is empty; each failure is +// {index, detail} with no status_code. +function partialBatchRejection(events: unknown[]) { + return axiosRejection(BACKEND_VALIDATION_STATUS, { total: events.length, created: 0, failed: events.length, - results: events.map((_, index) => ({ index, status_code: BACKEND_VALIDATION_STATUS, detail })), - errors: events.map((_, index) => ({ index, detail })), + results: [], + errors: events.map((_, index) => ({ index, detail: { errors: BACKEND_EVENT_ERRORS } })), mode: 'partial', - }; + }); } function expectEveryEventRejected(count: number) { @@ -116,7 +148,7 @@ describe('EPCIS 2.0 import on /ingest', () => { it('sends an EPCIS JSON document to the batch endpoint once with all events', async () => { const eventList = (epcisDocument as { epcisBody: { eventList: unknown[] } }).epcisBody.eventList; expect(eventList.length).toBeGreaterThan(1); - mocks.ingestEpcisBatch.mockResolvedValue(rejectedBatchResponse(eventList)); + mocks.ingestEpcisBatch.mockRejectedValue(atomicBatchRejection(eventList)); await openEpcisTab(); uploadEpcisFile('events.json', JSON.stringify(epcisDocument), 'application/json'); @@ -130,17 +162,18 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.validateEpcis).not.toHaveBeenCalled(); - expect(await screen.findByText('No events accepted')).toBeInTheDocument(); - expect( - screen.getByText(`0 of ${eventList.length} events accepted; ${eventList.length} rejected.`), - ).toBeInTheDocument(); - expectEveryEventRejected(eventList.length); + // Atomic (default) rejection: the page shows detail.message only. The + // per-event errors carry no msg/message key, so detailToMessage drops them. + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(ATOMIC_BATCH_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); expect(mocks.refresh).not.toHaveBeenCalled(); }); it('sends a bare JSON array of events to the batch endpoint', async () => { const events = [{ ...SINGLE_EVENT }, { ...SINGLE_EVENT }]; - mocks.ingestEpcisBatch.mockResolvedValue(rejectedBatchResponse(events)); + const rejection = partialBatchRejection(events); + mocks.ingestEpcisBatch.mockRejectedValue(rejection); await openEpcisTab(); uploadEpcisFile('events.json', JSON.stringify(events), 'application/json'); @@ -151,18 +184,17 @@ describe('EPCIS 2.0 import on /ingest', () => { apiKey: 'cookie-managed', // pragma: allowlist secret events, }); - expect(await screen.findByText('No events accepted')).toBeInTheDocument(); - expect( - screen.getByText(`0 of ${events.length} events accepted; ${events.length} rejected.`), - ).toBeInTheDocument(); - expectEveryEventRejected(events.length); + // Partial rejection: the body has no detail key, so the page falls back + // to the error message and shows no per-event errors. + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(rejection.message)).toBeInTheDocument(); + expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + expect(mocks.refresh).not.toHaveBeenCalled(); }); it('sends a single-event JSON to the single-event endpoint', async () => { mocks.ingestEpcisEvent.mockRejectedValue( - Object.assign(new Error(`Request failed with status code ${BACKEND_VALIDATION_STATUS}`), { - response: { status: BACKEND_VALIDATION_STATUS, data: { detail: { errors: BACKEND_EVENT_ERRORS } } }, - }), + axiosRejection(BACKEND_VALIDATION_STATUS, { detail: { errors: BACKEND_EVENT_ERRORS } }), ); await openEpcisTab(); @@ -182,16 +214,10 @@ describe('EPCIS 2.0 import on /ingest', () => { }); it('sends an XML file to the XML endpoint untouched', async () => { - // EPCIS_XML carries an empty EventList, so the backend parses no events. - mocks.ingestEpcisXml.mockResolvedValue({ - total: 0, - created: 0, - failed: 0, - results: [], - errors: [], - format: 'EPCIS_XML_2.0', - mode: 'partial', - }); + // EPCIS_XML carries an empty EventList: router.py raises + // HTTPException(422, 'No EPCIS events found in XML document'). + const xmlDetail = 'No EPCIS events found in XML document'; + mocks.ingestEpcisXml.mockRejectedValue(axiosRejection(422, { detail: xmlDetail })); await openEpcisTab(); uploadEpcisFile('events.xml', EPCIS_XML, 'application/xml'); @@ -204,9 +230,8 @@ describe('EPCIS 2.0 import on /ingest', () => { }); expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); - // The page passes the response through unchanged. - expect(await screen.findByText('No events accepted')).toBeInTheDocument(); - expect(screen.getByText('0 of 0 events accepted; 0 rejected.')).toBeInTheDocument(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(xmlDetail)).toBeInTheDocument(); expect(mocks.refresh).not.toHaveBeenCalled(); }); From 626393cf630423ce43c6fd2bfd4de25bf2c66003 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:46:50 -0700 Subject: [PATCH 6/8] fix(ingest): list per-event errors for rejected EPCIS batches Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../pages/ingest-epcis-batch-errors.test.tsx | 134 ++++++++++++++++++ frontend/src/app/ingest/page.tsx | 9 ++ frontend/src/lib/epcis-upload.ts | 21 +++ 3 files changed, 164 insertions(+) create mode 100644 frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx diff --git a/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx new file mode 100644 index 000000000..43a906b89 --- /dev/null +++ b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import IngestPage from '@/app/ingest/page'; + +const mocks = vi.hoisted(() => ({ + ingestURL: vi.fn(), + ingestFile: vi.fn(), + ingestEpcisEvent: vi.fn(), + ingestEpcisBatch: vi.fn(), + ingestEpcisXml: vi.fn(), + validateEpcis: vi.fn(), + getIngestionJob: vi.fn(), + refresh: vi.fn(), + storeApiKey: vi.fn(), +})); + +vi.mock('@/lib/auth-context', () => ({ + useAuth: () => ({ apiKey: 'cookie-managed', setApiKey: mocks.storeApiKey }), // pragma: allowlist secret +})); +vi.mock('@/hooks/use-api', () => ({ + useIngestURL: () => ({ mutateAsync: mocks.ingestURL, reset: vi.fn(), isPending: false, isError: false }), + useIngestFile: () => ({ mutateAsync: mocks.ingestFile, reset: vi.fn(), isPending: false, isError: false }), + useIngestEpcisEvent: () => ({ mutateAsync: mocks.ingestEpcisEvent, reset: vi.fn(), isPending: false }), + useIngestEpcisBatch: () => ({ mutateAsync: mocks.ingestEpcisBatch, reset: vi.fn(), isPending: false }), + useIngestEpcisXml: () => ({ mutateAsync: mocks.ingestEpcisXml, reset: vi.fn(), isPending: false }), + useValidateEpcisEvent: () => ({ mutateAsync: mocks.validateEpcis, reset: vi.fn(), isPending: false }), +})); +vi.mock('@/lib/api-client', () => ({ + apiClient: { getIngestionJob: mocks.getIngestionJob }, +})); +vi.mock('@/hooks/use-dashboard-refresh', () => ({ + notifyDashboardRefresh: mocks.refresh, +})); +vi.mock('@/app/ingest/NormalizedDocumentViewer', () => ({ + NormalizedDocumentViewer: () =>
Normalized document viewer
, +})); + +// Data-free placeholders: two {} events. Shapes below are copied from the +// backend source; counts derive from EVENTS.length. +const EVENTS: Record[] = [{}, {}]; + +// _validate_epcis (services/ingestion/app/epcis/validation.py) output for {}, +// in order. +const BACKEND_EVENT_ERRORS = [ + ...['type', 'eventTime', 'action', 'bizStep'].map((field) => `Missing required EPCIS field '${field}'`), + 'Unsupported EPCIS event type', + 'Missing traceability lot code (fsma:traceabilityLotCode or cbvmda:lotNumber)', +]; +const STATUS = 400; +const JOINED = BACKEND_EVENT_ERRORS.join(': '); +// Exact message from _ingest_batch_events_db_atomic (persistence.py). +const ATOMIC_MESSAGE = + 'One or more events failed pre-DB validation. No events were persisted. ' + + 'Use ?mode=partial to opt in to per-event processing with HTTP 207 semantics.'; + +function axiosRejection(status: number, data: unknown) { + return Object.assign(new Error(`Request failed with status code ${status}`), { response: { status, data } }); +} + +// Atomic (default, _batch_transactional()): HTTPException(400, detail={...}). +function atomicRejection(events: unknown[]) { + return axiosRejection(STATUS, { + detail: { + error: 'batch_validation_failed', + mode: 'atomic', + message: ATOMIC_MESSAGE, + errors: events.map((_, index) => ({ index, status_code: STATUS, detail: { errors: BACKEND_EVENT_ERRORS } })), + }, + }); +} + +// Partial (?mode=partial or EPCIS_BATCH_TRANSACTIONAL=false): router.py +// returns the payload itself with status 400, no detail wrapper. +function partialRejection(events: unknown[]) { + return axiosRejection(STATUS, { + total: events.length, + created: 0, + failed: events.length, + results: [], + errors: events.map((_, index) => ({ index, detail: { errors: BACKEND_EVENT_ERRORS } })), + mode: 'partial', + }); +} + +async function uploadBatch() { + render(); + await userEvent.click(screen.getByRole('tab', { name: /epcis 2\.0/i })); + const input = document.getElementById('epcis-file-upload') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File([JSON.stringify(EVENTS)], 'events.json', { type: 'application/json' })] }, + }); + await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); +} + +function expectEveryEventListed() { + for (let index = 0; index < EVENTS.length; index += 1) { + expect(screen.getByText(`Event ${index + 1}: ${JOINED}`)).toBeInTheDocument(); + } + expect(screen.getAllByText(/^Event \d+:/)).toHaveLength(EVENTS.length); +} + +describe('EPCIS all-failed batch shows per-event errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists every event under the atomic message', async () => { + mocks.ingestEpcisBatch.mockRejectedValue(atomicRejection(EVENTS)); + await uploadBatch(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(ATOMIC_MESSAGE)).toBeInTheDocument(); + expectEveryEventListed(); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('lists every event for a partial-mode 400 body', async () => { + const rejection = partialRejection(EVENTS); + mocks.ingestEpcisBatch.mockRejectedValue(rejection); + await uploadBatch(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getByText(rejection.message)).toBeInTheDocument(); + expectEveryEventListed(); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('shows no event lines for a failure without per-event errors', async () => { + const rejection = axiosRejection(422, { detail: 'No EPCIS events found in XML document' }); + mocks.ingestEpcisBatch.mockRejectedValue(rejection); + await uploadBatch(); + expect(await screen.findByText('No EPCIS events found in XML document')).toBeInTheDocument(); + expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx index 83261482d..d2e0e43ac 100644 --- a/frontend/src/app/ingest/page.tsx +++ b/frontend/src/app/ingest/page.tsx @@ -32,6 +32,7 @@ import { parseEpcisJson, normalizeEpcisIngestResult, epcisErrorMessage, + epcisErrorEventMessages, type ParsedEpcisJson, } from '@/lib/epcis-upload'; import { notifyDashboardRefresh } from '@/hooks/use-dashboard-refresh'; @@ -134,6 +135,7 @@ export default function IngestPage() { const [epcisFile, setEpcisFile] = useState(null); const [epcisResult, setEpcisResult] = useState(null); const [epcisError, setEpcisError] = useState(null); + const [epcisErrorEvents, setEpcisErrorEvents] = useState([]); const [epcisValidation, setEpcisValidation] = useState<{ checked: number; valid: number; @@ -172,6 +174,7 @@ export default function IngestPage() { setEpcisFile(e.target.files[0]); setEpcisResult(null); setEpcisError(null); + setEpcisErrorEvents([]); setEpcisValidation(null); } }; @@ -235,6 +238,7 @@ export default function IngestPage() { setErrorColumnSuggestions([]); setEpcisResult(null); setEpcisError(null); + setEpcisErrorEvents([]); setEpcisValidation(null); return true; }; @@ -309,6 +313,7 @@ export default function IngestPage() { notifyDashboardRefresh(); } catch (error) { setEpcisError(epcisErrorMessage(error)); + setEpcisErrorEvents(epcisErrorEventMessages(error)); } finally { submitInFlightRef.current = false; setIsSubmitting(false); @@ -852,6 +857,9 @@ export default function IngestPage() {

{(activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : ingestMutation.error?.message) || 'An error occurred during ingestion'}

+ {activeTab === 'epcis' && epcisErrorEvents.map((message, index) => ( +

{message}

+ ))} {activeTab === 'file' && ( )} @@ -905,6 +913,7 @@ export default function IngestPage() { setEpcisFile(null); setEpcisResult(null); setEpcisError(null); + setEpcisErrorEvents([]); setEpcisValidation(null); setJobId(null); setJobStatus('queued'); diff --git a/frontend/src/lib/epcis-upload.ts b/frontend/src/lib/epcis-upload.ts index fda18c2d6..2de962658 100644 --- a/frontend/src/lib/epcis-upload.ts +++ b/frontend/src/lib/epcis-upload.ts @@ -111,6 +111,27 @@ export function normalizeEpcisIngestResult(data: EpcisIngestResponse | null | un return { total, accepted, rejected, errorMessages }; } +/** + * Per-event lines for a rejected batch, in both backend shapes: + * atomic (400, detail {error, mode, message, errors:[{index, status_code, detail}]}) + * and partial (400 body with no detail wrapper: {errors:[{index, detail}], ...}). + * Returns [] for any other failure. + */ +export function epcisErrorEventMessages(error: unknown): string[] { + if (!(error instanceof Error)) return []; + const data = (error as Error & { response?: { data?: unknown } }).response?.data; + if (!isRecord(data)) return []; + const source = isRecord(data.detail) ? data.detail : data; + if (!Array.isArray(source.errors)) return []; + const messages: string[] = []; + for (const entry of source.errors) { + if (!isRecord(entry) || typeof entry.index !== 'number') continue; + const message = resultItemMessage(entry as { index?: number; detail?: unknown }); + if (message && !messages.includes(message)) messages.push(message); + } + return messages; +} + /** Render a backend or network failure the same terse way the CSV tab does. */ export function epcisErrorMessage(error: unknown): string { if (error instanceof Error) { From c48f3e98c926fb2a9cd9b80f8fa00f16a9f458a7 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:49:57 -0700 Subject: [PATCH 7/8] test(ingest-epcis): flip per-event assertions and use the real single-event 400 Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../pages/ingest-epcis-batch-errors.test.tsx | 21 +++++++++++++------ .../src/__tests__/pages/ingest-epcis.test.tsx | 13 ++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx index 43a906b89..0fc217905 100644 --- a/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx @@ -124,11 +124,20 @@ describe('EPCIS all-failed batch shows per-event errors', () => { expect(mocks.refresh).not.toHaveBeenCalled(); }); - it('shows no event lines for a failure without per-event errors', async () => { - const rejection = axiosRejection(422, { detail: 'No EPCIS events found in XML document' }); - mocks.ingestEpcisBatch.mockRejectedValue(rejection); - await uploadBatch(); - expect(await screen.findByText('No EPCIS events found in XML document')).toBeInTheDocument(); - expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + it('shows the single-event 400 once, with no Event N: lines', async () => { + // Single-event path: persistence.py raises + // HTTPException(400, detail={"errors": [...]}) for {}. + mocks.ingestEpcisEvent.mockRejectedValue(axiosRejection(STATUS, { detail: { errors: BACKEND_EVENT_ERRORS } })); + render(); + await userEvent.click(screen.getByRole('tab', { name: /epcis 2\.0/i })); + const input = document.getElementById('epcis-file-upload') as HTMLInputElement; + fireEvent.change(input, { + target: { files: [new File([JSON.stringify(EVENTS[0])], 'event.json', { type: 'application/json' })] }, + }); + await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expect(screen.getAllByText(JOINED)).toHaveLength(1); + expect(screen.queryAllByText(/^Event \d+:/)).toHaveLength(0); + expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/__tests__/pages/ingest-epcis.test.tsx b/frontend/src/__tests__/pages/ingest-epcis.test.tsx index 04750adbb..9902d0891 100644 --- a/frontend/src/__tests__/pages/ingest-epcis.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis.test.tsx @@ -162,11 +162,11 @@ describe('EPCIS 2.0 import on /ingest', () => { expect(mocks.ingestEpcisEvent).not.toHaveBeenCalled(); expect(mocks.ingestEpcisXml).not.toHaveBeenCalled(); expect(mocks.validateEpcis).not.toHaveBeenCalled(); - // Atomic (default) rejection: the page shows detail.message only. The - // per-event errors carry no msg/message key, so detailToMessage drops them. + // Atomic (default) rejection: detail.message heads one line per event. expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); expect(screen.getByText(ATOMIC_BATCH_MESSAGE)).toBeInTheDocument(); - expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + expectEveryEventRejected(eventList.length); + expect(screen.getAllByText(/^Event \d+:/)).toHaveLength(eventList.length); expect(mocks.refresh).not.toHaveBeenCalled(); }); @@ -184,11 +184,12 @@ describe('EPCIS 2.0 import on /ingest', () => { apiKey: 'cookie-managed', // pragma: allowlist secret events, }); - // Partial rejection: the body has no detail key, so the page falls back - // to the error message and shows no per-event errors. + // Partial rejection: the body has no detail key, so the error message is + // the heading and the body's errors give one line per event. expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); expect(screen.getByText(rejection.message)).toBeInTheDocument(); - expect(screen.queryByText(/^Event \d+:/)).not.toBeInTheDocument(); + expectEveryEventRejected(events.length); + expect(screen.getAllByText(/^Event \d+:/)).toHaveLength(events.length); expect(mocks.refresh).not.toHaveBeenCalled(); }); From 89ea2b5088b46860412f5b3d48365d42437e01d9 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:18:48 -0700 Subject: [PATCH 8/8] fix(ingest): clear prior upload errors before API-key save prepareSubmit returned early on a failed API-key save before resetting prior results, so stale per-event errors stayed on screen. Reset all result/error state and mutation state first. Show URL-tab save failures in the Ingestion Failed callout via urlError instead of statusError, which only renders while a jobId exists. Tests: EPCIS stale per-event errors cleared on save failure; URL tab shows the save error and sends no request. --- .../src/__tests__/pages/ingest-csv.test.tsx | 15 +++++++ .../pages/ingest-epcis-batch-errors.test.tsx | 16 ++++++++ frontend/src/app/ingest/page.tsx | 41 ++++++++++++------- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/frontend/src/__tests__/pages/ingest-csv.test.tsx b/frontend/src/__tests__/pages/ingest-csv.test.tsx index a5056e59b..d13d2749a 100644 --- a/frontend/src/__tests__/pages/ingest-csv.test.tsx +++ b/frontend/src/__tests__/pages/ingest-csv.test.tsx @@ -160,6 +160,21 @@ describe('CSV import results on /ingest', () => { expect(mocks.ingestFile).not.toHaveBeenCalled(); }); + it('shows the API-key save error on the URL tab without sending a request', async () => { + mocks.storeApiKey.mockRejectedValue(new Error('API key could not be saved. Please try again.')); + render(); + await userEvent.click(screen.getByRole('tab', { name: /url ingestion/i })); + fireEvent.change(screen.getByPlaceholderText('rge_...'), { target: { value: 'rge_replacement_key' } }); // pragma: allowlist secret + fireEvent.change(screen.getByPlaceholderText('https://example.com/regulatory-document.pdf'), { + target: { value: 'https://example.com/regulatory-document.pdf' }, + }); + await userEvent.click(screen.getByRole('button', { name: /ingest|upload & process/i })); + + expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument(); + expect(screen.getByText('Ingestion Failed')).toBeInTheDocument(); + expect(mocks.ingestURL).not.toHaveBeenCalled(); + }); + it('offers the CTE Jev suggested and re-uploads with it only on confirmation', async () => { const rejected = Object.assign(new Error('No valid rows found: Row 2: No CTE type'), { response: { diff --git a/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx index 0fc217905..eb89af9df 100644 --- a/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx +++ b/frontend/src/__tests__/pages/ingest-epcis-batch-errors.test.tsx @@ -140,4 +140,20 @@ describe('EPCIS all-failed batch shows per-event errors', () => { expect(screen.queryAllByText(/^Event \d+:/)).toHaveLength(0); expect(mocks.ingestEpcisBatch).not.toHaveBeenCalled(); }); + + it('clears the previous per-event errors when the API-key save fails', async () => { + mocks.ingestEpcisBatch.mockRejectedValue(atomicRejection(EVENTS)); + await uploadBatch(); + expect(await screen.findByText('Ingestion Failed')).toBeInTheDocument(); + expectEveryEventListed(); + + mocks.storeApiKey.mockRejectedValue(new Error('API key could not be saved. Please try again.')); + fireEvent.change(screen.getByPlaceholderText('rge_...'), { target: { value: 'rge_replacement_key' } }); // pragma: allowlist secret + await userEvent.click(screen.getByRole('button', { name: /upload & process/i })); + + expect(await screen.findByText('API key could not be saved. Please try again.')).toBeInTheDocument(); + expect(screen.queryAllByText(/^Event \d+:/)).toHaveLength(0); + expect(screen.queryByText(ATOMIC_MESSAGE)).not.toBeInTheDocument(); + expect(mocks.ingestEpcisBatch).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/src/app/ingest/page.tsx b/frontend/src/app/ingest/page.tsx index 6f6500eb0..ab8670b68 100644 --- a/frontend/src/app/ingest/page.tsx +++ b/frontend/src/app/ingest/page.tsx @@ -123,6 +123,9 @@ export default function IngestPage() { const [jobId, setJobId] = useState(null); const [jobStatus, setJobStatus] = useState('queued'); const [statusError, setStatusError] = useState(null); + // URL-tab error for a failed API-key save; URL ingest errors otherwise come + // from ingestMutation, which has no setter. + const [urlError, setUrlError] = useState(null); // Dashboard "No data yet" tiles deep-link to /ingest?tab=file (CSV upload). const [activeTab, setActiveTab] = useState(() => { const tab = searchParams.get('tab'); @@ -213,6 +216,26 @@ export default function IngestPage() { // browser proxy reads it from the cookie, then clear prior result state. // Returns false when the caller should stop without sending any request. const prepareSubmit = async (): Promise => { + // Clear the previous upload's results and errors first, so a failed + // API-key save below never leaves stale error lines on screen. + setJobId(null); + setJobStatus('queued'); + setStatusError(null); + setCsvResult(null); + setCsvError(null); + setCteSuggestion(null); + setErrorColumnSuggestions([]); + setEpcisResult(null); + setEpcisError(null); + setEpcisErrorEvents([]); + setEpcisValidation(null); + setUrlError(null); + ingestMutation.reset(); + ingestFileMutation.reset(); + ingestEpcisEventMutation.reset(); + ingestEpcisBatchMutation.reset(); + ingestEpcisXmlMutation.reset(); + validateEpcisMutation.reset(); // Store the API key for future use if (!isAuthenticated && apiKey !== storedApiKey) { // The browser proxy reads the cookie written by setApiKey and ignores @@ -224,21 +247,10 @@ export default function IngestPage() { const message = error instanceof Error ? error.message : 'Unable to save API key'; if (activeTab === 'file') setCsvError(message); else if (activeTab === 'epcis') setEpcisError(message); - else setStatusError(message); + else setUrlError(message); return false; } } - setJobId(null); - setJobStatus('queued'); - setStatusError(null); - setCsvResult(null); - setCsvError(null); - setCteSuggestion(null); - setErrorColumnSuggestions([]); - setEpcisResult(null); - setEpcisError(null); - setEpcisErrorEvents([]); - setEpcisValidation(null); return true; }; @@ -823,13 +835,13 @@ export default function IngestPage() { )} - {((activeTab === 'url' && ingestMutation.isError) || (activeTab === 'file' && csvError) || (activeTab === 'epcis' && epcisError)) && ( + {((activeTab === 'url' && (urlError || ingestMutation.isError)) || (activeTab === 'file' && csvError) || (activeTab === 'epcis' && epcisError)) && (

Ingestion Failed

- {(activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : ingestMutation.error?.message) || 'An error occurred during ingestion'} + {(activeTab === 'file' ? csvError : activeTab === 'epcis' ? epcisError : urlError || ingestMutation.error?.message) || 'An error occurred during ingestion'}

{activeTab === 'epcis' && epcisErrorEvents.map((message, index) => (

{message}

@@ -888,6 +900,7 @@ export default function IngestPage() { setEpcisError(null); setEpcisErrorEvents([]); setEpcisValidation(null); + setUrlError(null); setJobId(null); setJobStatus('queued'); ingestMutation.reset();