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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 146 additions & 22 deletions frontend/src/__tests__/api/inflow-lab-demo-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' } },
),
Expand All @@ -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' }],
});
});

Expand All @@ -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',
Expand All @@ -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',
);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
});
Expand All @@ -281,7 +284,7 @@ describe('Inflow Lab public proxy', () => {
{
type: 'ObjectEvent',
tenantId: 'also-vanish',
customer_id: 'c1',
customer_id: 'z',
bizStep: 'shipping',
},
],
Expand All @@ -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'] }) },
);
Expand Down Expand Up @@ -468,14 +471,109 @@ 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();
expect(sanitizePathSegment('a/b')).toBeNull();
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', '');
Expand All @@ -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}`;
Expand All @@ -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,
})}`,
),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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);
});
});
13 changes: 11 additions & 2 deletions frontend/src/app/api/inflow-lab-demo/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
29 changes: 27 additions & 2 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading