Skip to content
Merged
53 changes: 53 additions & 0 deletions frontend/src/__tests__/api/inflow-lab-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,59 @@ describe('Inflow Lab API proxy', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('allows GET scenarios and rejects POST scenarios', async () => {
mockSessionThenUpstream(
new Response(JSON.stringify({ scenarios: [{ id: 'leafy_greens_supplier', label: 'Leafy greens supplier' }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);

const allowed = await GET(
makeRequest({ url: 'http://localhost/api/inflow-lab/scenarios' }),
{ params: Promise.resolve({ path: ['scenarios'] }) },
);

expect(allowed.status).toBe(200);
expect(mockFetch).toHaveBeenNthCalledWith(
2,
new URL('http://inflow.test/api/scenarios'),
expect.objectContaining({
method: 'GET',
cache: 'no-store',
redirect: 'manual',
}),
);
await expect(allowed.json()).resolves.toEqual({
scenarios: [{ id: 'leafy_greens_supplier', label: 'Leafy greens supplier' }],
});

mockFetch.mockClear();
// Session must validate before the allowlist rejects the method/path.
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ tenant_id: '11111111-1111-4111-8111-111111111111' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const rejected = await POST(
makeRequest({
method: 'POST',
url: 'http://localhost/api/inflow-lab/api/scenarios',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'leafy_greens_supplier' }),
}),
{ params: Promise.resolve({ path: ['api', 'scenarios'] }) },
);
expect(rejected.status).toBe(400);
await expect(rejected.json()).resolves.toMatchObject({
ok: false,
code: 'INVALID_PATH',
});
// Auth/me only — no upstream proxy call for disallowed POST scenarios.
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('returns exact 404 in production unless the server-only proxy flag is enabled', async () => {
vi.stubEnv('NODE_ENV', 'production');
vi.stubEnv('REGENGINE_ENABLE_INFLOW_LAB_PROXY', '');
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/__tests__/lib/inflow-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { inflowErrorMessage } from '@/lib/inflow-error';

describe('Inflow connection errors', () => {
it('explains an empty disabled-route 404 without claiming the backend is down', () => {
expect(inflowErrorMessage(404, {})).toContain('Core backend health does not include the simulator');
});
it('preserves a specific upstream error', () => {
expect(inflowErrorMessage(404, { error: 'Fixture missing' })).toBe('Fixture missing');
});
it('reports other status failures', () => {
expect(inflowErrorMessage(502, {})).toBe('Inflow Lab request failed: 502');
});
it('preserves a session recovery hint', () => {
expect(
inflowErrorMessage(401, {
error: 'Authenticated browser session required',
hint: 'Refresh the page and sign in again before using live Inflow Lab actions.',
}),
).toContain('Refresh the page and sign in again');
});
});
24 changes: 20 additions & 4 deletions frontend/src/app/api/inflow-lab/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const JSON_CONTENT_TYPES = ["application/json", "application/problem+json"];

type BoundaryMode = "simulator" | "live-export";
type EndpointContract = {
name: "health" | "status" | "records" | "lineage" | "run" | "reset" | "fixture" | "export";
name: "health" | "status" | "records" | "lineage" | "run" | "reset" | "fixture" | "export" | "scenarios";
boundary: BoundaryMode;
stream: boolean;
};
Expand Down Expand Up @@ -106,6 +106,10 @@ function endpointContract(path: string, method: string): EndpointContract | null
if (method === "GET" && path === "/api/events") {
return { name: "records", boundary: "simulator", stream: false };
}
// Accept both /api/scenarios (service path) and /scenarios (short path).
if (method === "GET" && (path === "/api/scenarios" || path === "/scenarios")) {
return { name: "scenarios", boundary: "simulator", stream: false };
}
if (method === "GET" && /^\/api\/lineage\/[^/]+$/.test(path)) {
return { name: "lineage", boundary: "simulator", stream: false };
}
Expand All @@ -128,6 +132,12 @@ function endpointContract(path: string, method: string): EndpointContract | null
return null;
}

/** Map short proxy paths onto the Inflow Lab service routes. */
function upstreamPathFor(relativePath: string): string {
if (relativePath === "/scenarios") return "/api/scenarios";
return relativePath;
}

function copyAllowedSearchParams(source: URLSearchParams, target: URLSearchParams) {
for (const [key, value] of source.entries()) {
if (/^[a-zA-Z0-9_.-]{1,64}$/.test(key)) {
Expand Down Expand Up @@ -193,7 +203,12 @@ function responseHeaders(contract: EndpointContract, upstream?: Response) {
function isOptionalReadProbe(contract: EndpointContract, method: string) {
return (
method === "GET" &&
(contract.name === "health" || contract.name === "status" || contract.name === "records")
(
contract.name === "health"
|| contract.name === "status"
|| contract.name === "records"
|| contract.name === "scenarios"
)
);
}

Expand All @@ -204,6 +219,7 @@ function optionalOfflineResponse(contract: EndpointContract, detail?: string, up
available: false,
offline: true,
events: contract.name === "records" ? [] : undefined,
scenarios: contract.name === "scenarios" ? [] : undefined,
status: contract.name === "status" ? "offline" : undefined,
health: contract.name === "health" ? "offline" : undefined,
detail,
Expand Down Expand Up @@ -273,12 +289,12 @@ async function proxyInflowLab(request: NextRequest, params: Promise<{ path?: str
400,
"INVALID_PATH",
{
hint: "Allowed Inflow Lab routes are health, status, records, lineage, run/reset, fixture load, and export links.",
hint: "Allowed Inflow Lab routes are health, status, records, scenarios, lineage, run/reset, fixture load, and export links.",
}
);
}

const upstreamUrl = new URL(`${serviceBaseUrl()}${relativePath}`);
const upstreamUrl = new URL(`${serviceBaseUrl()}${upstreamPathFor(relativePath)}`);
copyAllowedSearchParams(request.nextUrl.searchParams, upstreamUrl.searchParams);

const headers = new Headers();
Expand Down
Loading
Loading