forest-express
Version:
Official package for all Forest Express Lianas
283 lines (237 loc) • 10.3 kB
JavaScript
const nock = require('nock');
const request = require('supertest');
const zlib = require('zlib');
const createServer = require('../helpers/create-server');
const auth = require('../../src/services/auth');
const initWorkflowExecutorRoutes = require('../../src/routes/workflow-executor');
const envSecret = Array(65).join('0');
const authSecret = Array(65).join('1');
const executorBase = 'http://workflow-executor.test:4001';
function mockEnsureAuthenticated() {
jest.spyOn(auth, 'ensureAuthenticated').mockImplementation((req, res, next) => next());
}
describe('routes > workflow executor proxy', () => {
// NOTICE: Run teardown unconditionally so a failed assertion cannot leak a nock
// interceptor or a mock into the next test.
// eslint-disable-next-line jest/no-hooks
afterEach(() => {
jest.clearAllMocks();
nock.cleanAll();
});
describe('generic forwarding', () => {
it('forwards a run GET (caller includes runs/) with query params, returning the response', async () => {
mockEnsureAuthenticated();
const app = await createServer(envSecret, authSecret, { workflowExecutorUrl: executorBase });
const scope = nock(executorBase)
.get('/runs/run-123')
.query({ foo: 'bar' })
.reply(200, { id: 'run-123', state: 'pending' });
const response = await request(app)
.get('/forest/_internal/executor/runs/run-123?foo=bar');
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({ id: 'run-123', state: 'pending' });
expect(scope.isDone()).toBe(true);
});
it('forwards a POST trigger with the JSON body to /runs/:runId/trigger', async () => {
mockEnsureAuthenticated();
const app = await createServer(envSecret, authSecret, { workflowExecutorUrl: executorBase });
const scope = nock(executorBase)
.post('/runs/run-42/trigger', { step: 'approve', value: 42 })
.reply(200, { ok: true });
const response = await request(app)
.post('/forest/_internal/executor/runs/run-42/trigger')
.send({ step: 'approve', value: 42 });
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({ ok: true });
expect(scope.isDone()).toBe(true);
});
it('forwards a non-runs route verbatim (no /runs prefix injected)', async () => {
mockEnsureAuthenticated();
const app = await createServer(envSecret, authSecret, { workflowExecutorUrl: executorBase });
const scope = nock(executorBase)
.delete('/mcp-oauth-credentials')
.reply(200, { deleted: true });
const response = await request(app)
.delete('/forest/_internal/executor/mcp-oauth-credentials');
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({ deleted: true });
expect(scope.isDone()).toBe(true);
});
it('forwards a 4xx executor response verbatim', async () => {
mockEnsureAuthenticated();
const app = await createServer(envSecret, authSecret, { workflowExecutorUrl: executorBase });
nock(executorBase).get('/runs/run-456').reply(422, { error: 'invalid_step' });
const response = await request(app)
.get('/forest/_internal/executor/runs/run-456');
expect(response.status).toBe(422);
expect(response.body).toStrictEqual({ error: 'invalid_step' });
});
it('returns 503 when the executor is unreachable', async () => {
mockEnsureAuthenticated();
const app = await createServer(envSecret, authSecret, { workflowExecutorUrl: executorBase });
nock(executorBase)
.get('/runs/run-789')
.replyWithError({ code: 'ECONNREFUSED', message: 'refused' });
const response = await request(app)
.get('/forest/_internal/executor/runs/run-789');
expect(response.status).toBe(503);
expect(response.body).toStrictEqual({ error: 'workflow_executor_unreachable' });
});
});
describe('header forwarding', () => {
// The test helper's express-jwt layer can't accept tokens signed with the
// agent's authSecret, so we exercise the handler directly: capture it via
// a fake express app and invoke it with a mock req/res.
function captureHandler() {
let handler;
const fakeApp = { all: jest.fn((_path, _auth, fn) => { handler = fn; }) };
initWorkflowExecutorRoutes(
fakeApp,
{ workflowExecutorUrl: executorBase },
{ logger: { error: jest.fn() } },
);
return handler;
}
function fakeRes() {
const res = { status: jest.fn(), json: jest.fn(), set: jest.fn() };
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
res.set.mockReturnValue(res);
return res;
}
it('forwards all client headers except hop-by-hop / host / content-length', async () => {
const handler = captureHandler();
const scope = nock(executorBase, {
reqheaders: {
authorization: 'Bearer abc',
cookie: 'forest_session_token=xyz',
'x-custom': 'forwarded',
},
badheaders: ['te', 'host'],
})
.get('/runs/run-1')
.reply(200, { ok: true });
const req = {
method: 'GET',
params: { 0: 'runs/run-1' },
query: {},
headers: {
authorization: 'Bearer abc',
cookie: 'forest_session_token=xyz',
'x-custom': 'forwarded',
te: 'trailers',
host: 'agent.test',
},
};
const res = fakeRes();
await handler(req, res);
expect(scope.isDone()).toBe(true);
expect(res.status).toHaveBeenCalledWith(200);
});
it('forwards executor response headers except hop-by-hop / encoding ones', async () => {
const handler = captureHandler();
// gzipped body + content-type so superagent actually decompresses it. The proxy then
// re-emits plain JSON via res.json(), so it must NOT relay the upstream content-encoding.
const gzipped = zlib.gzipSync(JSON.stringify({ ok: true }));
nock(executorBase)
.get('/runs/run-1')
.reply(200, gzipped, {
'content-type': 'application/json',
'content-encoding': 'gzip',
'x-executor-custom': 'passthrough-value',
});
const req = {
method: 'GET', params: { 0: 'runs/run-1' }, query: {}, headers: {},
};
const res = fakeRes();
await handler(req, res);
// Body was decoded and re-emitted as plain JSON...
expect(res.json).toHaveBeenCalledWith({ ok: true });
expect(res.set).toHaveBeenCalledWith('x-executor-custom', 'passthrough-value');
// ...so the upstream content-encoding must not be relayed onto plain bytes.
expect(res.set).not.toHaveBeenCalledWith('content-encoding', expect.anything());
});
it('does not forward the client accept-encoding (superagent manages its own)', async () => {
const handler = captureHandler();
// A forwarded client value would override superagent's own header; assert it doesn't.
const scope = nock(executorBase)
.matchHeader('accept-encoding', (value) => value !== 'identity')
.get('/runs/run-1')
.reply(200, { ok: true });
const req = {
method: 'GET', params: { 0: 'runs/run-1' }, query: {}, headers: { 'accept-encoding': 'identity' },
};
const res = fakeRes();
await handler(req, res);
expect(scope.isDone()).toBe(true);
});
});
describe('guards against escaping the executor origin (SSRF)', () => {
function captureHandler() {
let handler;
const fakeApp = { all: jest.fn((_path, _auth, fn) => { handler = fn; }) };
initWorkflowExecutorRoutes(
fakeApp,
{ workflowExecutorUrl: executorBase },
{ logger: { error: jest.fn() } },
);
return handler;
}
it.each([
'..',
'../mcp-oauth-credentials',
'run-123/../../mcp-oauth-credentials',
'%2e%2e/mcp-oauth-credentials',
])('rejects %p with 404 and never forwards', async (evilPath) => {
const handler = captureHandler();
const scope = nock(executorBase).get(/.*/).reply(200, {});
const req = {
method: 'GET', params: { 0: evilPath }, query: {}, headers: {},
};
const res = { status: jest.fn(), json: jest.fn() };
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(scope.isDone()).toBe(false);
nock.cleanAll();
});
it('does not follow an executor redirect to an off-origin host', async () => {
const handler = captureHandler();
// Executor replies 3xx pointing off-origin; the proxy must NOT chase it.
nock(executorBase)
.get('/runs/run-1')
.reply(302, '', { Location: 'http://evil.com/stolen' });
const evil = nock('http://evil.com').get('/stolen').reply(200, { hacked: true });
const req = {
method: 'GET', params: { 0: 'runs/run-1' }, query: {}, headers: {},
};
const res = { status: jest.fn(), json: jest.fn(), set: jest.fn() };
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
res.set.mockReturnValue(res);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(302); // 3xx returned to the client, not followed
expect(evil.isDone()).toBe(false); // never reached the off-origin host
nock.cleanAll();
});
});
describe('lazy mount', () => {
const fakeLogger = { error: jest.fn() };
it('does not register any route when workflowExecutorUrl is not set', () => {
const fakeApp = { all: jest.fn() };
initWorkflowExecutorRoutes(fakeApp, {}, { logger: fakeLogger });
expect(fakeApp.all).not.toHaveBeenCalled();
});
it('registers a single catch-all route when workflowExecutorUrl is set', () => {
const fakeApp = { all: jest.fn() };
initWorkflowExecutorRoutes(
fakeApp,
{ workflowExecutorUrl: executorBase },
{ logger: fakeLogger },
);
expect(fakeApp.all).toHaveBeenCalledTimes(1);
expect(fakeApp.all.mock.calls[0][0]).toBe('/forest/_internal/executor/*');
});
});
});