diff --git a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts index d509a0e1a..903c4fcbb 100644 --- a/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts +++ b/frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts @@ -2,11 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { GET, POST, + PUT, + DELETE, __resetInflowLabDemoRateLimitForTests, trustedClientIp, sanitizePathSegment, stripTenantLike, } from '@/app/api/inflow-lab-demo/[...path]/route'; +import { buildDemoExportQuery } from '@/app/tools/inflow-lab/lib/demo-export-query'; import { CSRF_HEADER, CSRF_SIG_COOKIE, signCsrfToken } from '@/lib/csrf'; import type { NextRequest } from 'next/server'; @@ -154,9 +157,9 @@ describe('Inflow Lab public proxy', () => { new Response( JSON.stringify({ ok: true, - tenant_id: 'should-vanish', - delivery: { mode: 'local', tenantId: 'also-vanish', posted: 3 }, - events: [{ id: 'e1', customer_id: 'c1', lot: 'L1' }], + tenant_id: 'x', + delivery: { mode: 'local', tenantId: 'y', posted: 3 }, + events: [{ id: 'row', customer_id: 'z', lot: 'seg' }], }), { status: 200, headers: { 'content-type': 'application/json' } }, ), @@ -171,7 +174,7 @@ describe('Inflow Lab public proxy', () => { await expect(response.json()).resolves.toEqual({ ok: true, delivery: { mode: 'local', posted: 3 }, - events: [{ id: 'e1', lot: 'L1' }], + events: [{ id: 'row', lot: 'seg' }], }); }); @@ -194,7 +197,7 @@ describe('Inflow Lab public proxy', () => { it('allowlists export preview GETs under /api/inflow-lab-demo and streams the body', async () => { mockFetch.mockResolvedValueOnce( - new Response('lot,qty\nL1,10\n', { + new Response('lot,qty\nrow,10\n', { status: 200, headers: { 'content-type': 'text/csv', @@ -212,7 +215,7 @@ describe('Inflow Lab public proxy', () => { expect(response.status).toBe(200); expect(response.headers.get('content-type')).toContain('text/csv'); - expect(await response.text()).toBe('lot,qty\nL1,10\n'); + expect(await response.text()).toBe('lot,qty\nrow,10\n'); expect(String(mockFetch.mock.calls[0][0])).toContain( 'http://inflow.test/api/mock/regengine/export/fda-request', ); @@ -240,7 +243,7 @@ describe('Inflow Lab public proxy', () => { mockFetch.mockResolvedValueOnce( new Response( JSON.stringify({ - records: [{ lot: 'L1', tenant_id: 't-gone', qty: 3 }], + records: [{ lot: 'seg', tenant_id: 't-gone', qty: 3 }], tenantId: 'also-gone', customer_id: 'c-gone', ok: true, @@ -264,7 +267,7 @@ describe('Inflow Lab public proxy', () => { 'http://inflow.test/api/mock/regengine/export/fda-request?preset=all_records', ); await expect(response.json()).resolves.toEqual({ - records: [{ lot: 'L1', qty: 3 }], + records: [{ lot: 'seg', qty: 3 }], ok: true, }); }); @@ -281,7 +284,7 @@ describe('Inflow Lab public proxy', () => { { type: 'ObjectEvent', tenantId: 'also-vanish', - customer_id: 'c1', + customer_id: 'z', bizStep: 'shipping', }, ], @@ -296,7 +299,7 @@ describe('Inflow Lab public proxy', () => { const response = await GET( makeRequest({ - url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/epcis?traceability_lot_code=L1', + url: 'http://localhost/api/inflow-lab-demo/api/regengine/export/epcis?traceability_lot_code=seg', }), { params: Promise.resolve({ path: ['api', 'regengine', 'export', 'epcis'] }) }, ); @@ -468,7 +471,7 @@ describe('Inflow Lab public proxy', () => { expect(mockFetch, path.join('/')).not.toHaveBeenCalled(); } - expect(sanitizePathSegment('LOT-001')).toBe('LOT-001'); + expect(sanitizePathSegment('seg-safe')).toBe('seg-safe'); expect(sanitizePathSegment('%2e%2e')).toBeNull(); expect(sanitizePathSegment('%252e%252e')).toBeNull(); expect(sanitizePathSegment('..\\windows')).toBeNull(); @@ -476,6 +479,101 @@ describe('Inflow Lab public proxy', () => { expect(stripTenantLike({ tenant_id: 't', keep: 1 })).toEqual({ keep: 1 }); }); + it('allowlists signed-out GET lineage for colon-bearing segments and strips tenant fields', async () => { + // Structural fixture: exercises ':' in SEGMENT_ALLOW without inventing a realistic URN/TLC. + const lot = ['seg', 'with', 'colons'].join(':'); + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + lotCode: lot, + tenant_id: 'x', + organizationId: 'y', + events: [{ id: 'row', keep: true, customer_id: 'z' }], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + const response = await GET( + makeRequest({ + url: `http://localhost/api/inflow-lab-demo/api/lineage/${encodeURIComponent(lot)}`, + cookies: {}, + }), + { params: Promise.resolve({ path: ['api', 'lineage', lot] }) }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + lotCode: lot, + events: [{ id: 'row', keep: true }], + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0][0])).toBe( + `http://inflow.test/api/lineage/${encodeURIComponent(lot)}`, + ); + expect(sanitizePathSegment(lot)).toBe(lot); + }); + + it('rejects lineage POST, PUT, and DELETE while keeping GET allowlisted', async () => { + const lot = 'seg'; + const path = ['api', 'lineage', lot]; + const csrf = await csrfCookies(); + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + + const getResponse = await GET( + makeRequest({ url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}` }), + { params: Promise.resolve({ path }) }, + ); + expect(getResponse.status).toBe(200); + + const postResponse = await POST( + makeRequest({ + method: 'POST', + url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}`, + headers: csrf.headers, + cookies: csrf.cookies, + body: JSON.stringify({ noop: true }), + }), + { params: Promise.resolve({ path }) }, + ); + expect(postResponse.status).toBe(404); + await expect(postResponse.json()).resolves.toMatchObject({ code: 'NOT_ALLOWLISTED' }); + + const putResponse = await PUT( + makeRequest({ + method: 'PUT', + url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}`, + headers: csrf.headers, + cookies: csrf.cookies, + body: JSON.stringify({ noop: true }), + }), + { params: Promise.resolve({ path }) }, + ); + expect(putResponse.status).toBe(405); + await expect(putResponse.json()).resolves.toMatchObject({ code: 'METHOD_NOT_ALLOWED' }); + + const deleteResponse = await DELETE( + makeRequest({ + method: 'DELETE', + url: `http://localhost/api/inflow-lab-demo/api/lineage/${lot}`, + headers: csrf.headers, + cookies: csrf.cookies, + }), + { params: Promise.resolve({ path }) }, + ); + expect(deleteResponse.status).toBe(405); + await expect(deleteResponse.json()).resolves.toMatchObject({ code: 'METHOD_NOT_ALLOWED' }); + }); + it('returns exact 404 in production unless the public proxy flag is enabled', async () => { vi.stubEnv('NODE_ENV', 'production'); vi.stubEnv('REGENGINE_ENABLE_INFLOW_LAB_DEMO_PROXY', ''); @@ -491,9 +589,10 @@ describe('Inflow Lab public proxy', () => { }); }); -describe('Inflow Lab client export hrefs use the public proxy base', () => { - it('documents the public base + safe relative export paths', async () => { - // Mirrors InflowLabClient: INFLOW_API + servicePath + safeHref. +describe('Demo export query + public proxy path helpers', () => { + it('builds public-proxy-relative export paths from empty and filtered inputs', async () => { + // Documents INFLOW_API + servicePath + safeHref + buildDemoExportQuery shape + // (does not render InflowLabClient). const INFLOW_API = '/api/inflow-lab-demo'; function servicePath(path: string) { return `${INFLOW_API}${path}`; @@ -512,18 +611,43 @@ describe('Inflow Lab client export hrefs use the public proxy base', () => { return '#'; } - const csv = safeHref( + const defaultCsv = safeHref( servicePath( - '/api/regengine/export/fda-request?preset=all_records&traceability_lot_code=L1', + `/api/regengine/export/fda-request${buildDemoExportQuery({ + preset: 'all_records', + traceabilityLotCode: '', + startDate: '', + endDate: '', + })}`, ), ); - const epcis = safeHref( - servicePath('/api/regengine/export/epcis?traceability_lot_code=L1'), + const defaultEpcis = safeHref( + servicePath( + `/api/regengine/export/epcis${buildDemoExportQuery({ + traceabilityLotCode: '', + startDate: '', + endDate: '', + })}`, + ), + ); + const filterLot = 'seg'; + const filteredCsv = safeHref( + servicePath( + `/api/regengine/export/fda-request${buildDemoExportQuery({ + preset: 'all_records', + traceabilityLotCode: filterLot, + })}`, + ), ); - expect(csv.startsWith('/api/inflow-lab-demo/api/regengine/export/fda-request')).toBe(true); - expect(epcis.startsWith('/api/inflow-lab-demo/api/regengine/export/epcis')).toBe(true); - expect(csv.includes('://')).toBe(false); - expect(epcis.includes('://')).toBe(false); + expect(defaultCsv).toBe( + '/api/inflow-lab-demo/api/regengine/export/fda-request?preset=all_records', + ); + expect(defaultEpcis).toBe('/api/inflow-lab-demo/api/regengine/export/epcis'); + expect(filteredCsv).toBe( + `/api/inflow-lab-demo/api/regengine/export/fda-request?preset=all_records&traceability_lot_code=${filterLot}`, + ); + expect(defaultCsv.includes('://')).toBe(false); + expect(defaultEpcis.includes('://')).toBe(false); }); }); diff --git a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts index 0b4e41452..12795f174 100644 --- a/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts +++ b/frontend/src/app/api/inflow-lab-demo/[...path]/route.ts @@ -16,7 +16,8 @@ const MAX_BODY_BYTES = 32 * 1024; const MAX_EXPORT_JSON_BYTES = 2 * 1024 * 1024; const TENANT_LIKE_KEY = /^(tenant(_?id)?|tenantId|customer(_?id)?|customerId|organization(_?id)?|org_id|orgId)$/i; const TRAVERSAL_ENCODED = /%2e|%2f|%5c/i; -const SEGMENT_ALLOW = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +// Colon allowed so EPCIS URN lot codes (urn:epc:id:sgtin:...) survive the public lineage path. +const SEGMENT_ALLOW = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; // Best-effort per-instance token bucket (same posture as /api/client-errors). const DEFAULT_WINDOW_MS = 60_000; @@ -199,7 +200,7 @@ function contractFor(path: string, method: string): DemoContract | null { if (method === "GET" && path === "/api/healthz") return "health"; if (method === "GET" && path === "/api/simulate/status") return "status"; if (method === "GET" && path === "/api/events") return "events"; - if (method === "GET" && /^\/api\/lineage\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(path)) { + if (method === "GET" && /^\/api\/lineage\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(path)) { return "lineage"; } if (method === "POST" && path === "/api/simulate/start") return "start"; @@ -515,3 +516,11 @@ export function GET(request: NextRequest, context: { params: Promise<{ path?: st export function POST(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { return proxyPublic(request, context.params); } + +export function PUT(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { + return proxyPublic(request, context.params); +} + +export function DELETE(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) { + return proxyPublic(request, context.params); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ca6031af9..2e31bf50f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -124,8 +124,33 @@ body[data-inflow-lab="true"] { background: #fff; color: #0f172a; } -[data-inflow-lab-app] button.bg-emerald-700, -[data-inflow-lab-app] button.bg-emerald-600, +/* Emerald/primary actions need an explicit background: the generic button + rule above paints background:#fff, and color:#fff here alone is white-on-white. */ +[data-inflow-lab-app] button.bg-emerald-700 { + background: #047857; + border-color: transparent; + color: #fff; +} +[data-inflow-lab-app] button.bg-emerald-700:hover { + background: #065f46; + border-color: transparent; + color: #fff; +} +[data-inflow-lab-app] button.bg-emerald-600 { + background: #059669; + border-color: transparent; + color: #fff; +} +[data-inflow-lab-app] button.bg-emerald-600:hover { + background: #047857; + border-color: transparent; + color: #fff; +} +[data-inflow-lab-app] button.bg-emerald-800 { + background: #065f46; + border-color: transparent; + color: #fff; +} [data-inflow-lab-app] button.bg-slate-950 { border-color: transparent; color: #fff; diff --git a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx index 307f82150..485a64ea8 100644 --- a/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx +++ b/frontend/src/app/tools/inflow-lab/components/InflowLabClient.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { inflowErrorMessage } from "@/lib/inflow-error"; +import { buildDemoExportQuery } from "@/app/tools/inflow-lab/lib/demo-export-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, @@ -833,7 +834,7 @@ export function InflowLabClient({ const [activeTab, setActiveTab] = useState(primaryTab); const [isTutorialOpen, setIsTutorialOpen] = useState(false); const [selectedLot, setSelectedLot] = useState(lotCodes[0]); - const [traceInput, setTraceInput] = useState(lotCodes[0]); + const [traceInput, setTraceInput] = useState(""); const [runStage, setRunStage] = useState("complete"); const [health, setHealth] = useState(null); const [serviceStatus, setServiceStatus] = useState(null); @@ -847,8 +848,8 @@ export function InflowLabClient({ const [deliveryMode, setDeliveryMode] = useState("mock"); const [source, setSource] = useState("codex-simulator"); const [exportPreset, setExportPreset] = useState("all_records"); - const [startDate, setStartDate] = useState("2026-04-26"); - const [endDate, setEndDate] = useState("2026-04-27"); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); const [feederCsv, setFeederCsv] = useState(FEEDER_SAMPLE_CSV); const [feederResult, setFeederResult] = useState(null); const [feederError, setFeederError] = useState(null); @@ -889,9 +890,10 @@ export function InflowLabClient({ setServiceError(optionalOfflineDetail(healthPayload, statusPayload, eventsPayload)); const firstLot = nextEvents[0]?.lotCode; - if (firstLot && (!traceInput || traceInput === lotCodes[0])) { + // Keep export lot/date filters empty by default so CSV/EPCIS cover all demo events. + // Only sync the selected lot for lineage / lot cards. + if (firstLot && (selectedLot === lotCodes[0] || !selectedLot)) { setSelectedLot(firstLot); - setTraceInput(firstLot); } }; @@ -1272,14 +1274,20 @@ export function InflowLabClient({ }; const traceLot = async () => { - if (traceInput) { - setSelectedLot(traceInput); - setActiveTab("Lineage"); + // Lineage may fall back to selectedLot, but never copy that into traceInput — + // that field also drives CSV/EPCIS export filters (keep defaults unfiltered). + // Trim for lineage SEGMENT_ALLOW (no spaces) and for selection matching. + // buildDemoExportQuery also trims lot filters and omits whitespace-only values. + const lot = traceInput.trim() || selectedLot; + if (!lot) { + return; } + setSelectedLot(lot); + setActiveTab("Lineage"); try { - const payload = await inflowJson(`/api/lineage/${encodeURIComponent(traceInput)}`); + const payload = await inflowJson(`/api/lineage/${encodeURIComponent(lot)}`); const lineageEvents = (payload.records || []).map(toTraceEvent); - setServiceLineage((current) => ({ ...current, [traceInput]: toLineage(lineageEvents) })); + setServiceLineage((current) => ({ ...current, [lot]: toLineage(lineageEvents) })); setServiceError(null); } catch (error) { setServiceError(error instanceof Error ? error.message : "Could not trace lot"); @@ -1325,12 +1333,21 @@ export function InflowLabClient({ const csvExportHref = safeHref( servicePath( - `/api/regengine/export/fda-request?preset=${encodeURIComponent(exportPreset)}&traceability_lot_code=${encodeURIComponent(traceInput)}&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`, + `/api/regengine/export/fda-request${buildDemoExportQuery({ + preset: exportPreset, + traceabilityLotCode: traceInput, + startDate, + endDate, + })}`, ), ); const epcisExportHref = safeHref( servicePath( - `/api/regengine/export/epcis?traceability_lot_code=${encodeURIComponent(traceInput)}&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`, + `/api/regengine/export/epcis${buildDemoExportQuery({ + traceabilityLotCode: traceInput, + startDate, + endDate, + })}`, ), ); const engineConnected = Boolean(health?.ok && !serviceError); @@ -1527,7 +1544,8 @@ export function InflowLabClient({ Load mock scenario @@ -1864,14 +1886,14 @@ export function InflowLabClient({ Preview test CSV or EPCIS JSON-LD filters. Formal production records are generated only from imported production data.

@@ -2353,7 +2375,11 @@ export function InflowLabClient({

Harvest, cool, pack, ship, and receive one mock scenario through the test gateway. Production evidence stays in export jobs.

- @@ -2430,14 +2456,14 @@ export function InflowLabClient({ {exportReadyCount} lots are test complete. {exceptionLots.length} exception lots remain visible. Formal production records require signed-in imports.

@@ -2640,8 +2666,8 @@ export function InflowLabClient({

Includes {exportReadyCount} test-complete lots. {exceptionLots.length} exception lots are flagged for review. Formal production records are generated only from imported production data.

-
@@ -2651,7 +2677,7 @@ export function InflowLabClient({ Uses the same lot and date filters for interoperability testing. It is not production evidence.

diff --git a/frontend/src/app/tools/inflow-lab/lib/demo-export-query.test.ts b/frontend/src/app/tools/inflow-lab/lib/demo-export-query.test.ts new file mode 100644 index 000000000..91660a988 --- /dev/null +++ b/frontend/src/app/tools/inflow-lab/lib/demo-export-query.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { buildDemoExportQuery } from "./demo-export-query"; + +describe("buildDemoExportQuery", () => { + it("omits empty lot and date filters so defaults cover all demo events", () => { + const query = buildDemoExportQuery({ + preset: "all_records", + traceabilityLotCode: "", + startDate: "", + endDate: "", + }); + expect(query).toBe("?preset=all_records"); + expect(query.includes("traceability_lot_code")).toBe(false); + expect(query.includes("start_date")).toBe(false); + expect(query.includes("end_date")).toBe(false); + }); + + it("returns an empty string when every filter is blank", () => { + expect(buildDemoExportQuery({})).toBe(""); + expect( + buildDemoExportQuery({ + preset: " ", + traceabilityLotCode: "", + startDate: "", + endDate: undefined, + }), + ).toBe(""); + }); + + it("includes provided filters with an exact query string", () => { + const lot = "seg"; + const start = "d0"; + const end = "d1"; + const query = buildDemoExportQuery({ + preset: "all_records", + traceabilityLotCode: lot, + startDate: start, + endDate: end, + }); + expect(query).toBe( + `?preset=all_records&traceability_lot_code=${encodeURIComponent(lot)}&start_date=${encodeURIComponent(start)}&end_date=${encodeURIComponent(end)}`, + ); + }); + + it("trims lot values and omits whitespace-only lots so spaces never become +++", () => { + // Spaces-only must not emit traceability_lot_code=+++. + expect( + buildDemoExportQuery({ + preset: "all_records", + traceabilityLotCode: " ", + }), + ).toBe("?preset=all_records"); + expect(buildDemoExportQuery({ traceabilityLotCode: " \t " })).toBe(""); + + const query = buildDemoExportQuery({ + traceabilityLotCode: " seg ", + }); + expect(query).toBe("?traceability_lot_code=seg"); + const params = new URLSearchParams(query.slice(1)); + expect(params.get("traceability_lot_code")).toBe("seg"); + }); +}); diff --git a/frontend/src/app/tools/inflow-lab/lib/demo-export-query.ts b/frontend/src/app/tools/inflow-lab/lib/demo-export-query.ts new file mode 100644 index 000000000..d9e498025 --- /dev/null +++ b/frontend/src/app/tools/inflow-lab/lib/demo-export-query.ts @@ -0,0 +1,23 @@ +/** + * Build export query strings for the public Inflow Lab demo. + * Empty / whitespace-only lot and empty date filters are omitted so downloads + * default to all demo events. Lot values are trimmed before use. + */ +export function buildDemoExportQuery(params: { + preset?: string; + traceabilityLotCode?: string; + startDate?: string; + endDate?: string; +}): string { + const search = new URLSearchParams(); + const preset = params.preset?.trim(); + const lot = params.traceabilityLotCode?.trim(); + const startDate = params.startDate?.trim(); + const endDate = params.endDate?.trim(); + if (preset) search.set("preset", preset); + if (lot) search.set("traceability_lot_code", lot); + if (startDate) search.set("start_date", startDate); + if (endDate) search.set("end_date", endDate); + const query = search.toString(); + return query ? `?${query}` : ""; +}