Files
sencho/backend/src/__tests__/proxy-mount-order.test.ts
T
Anso 43a595905b fix(backend): restore remote proxy mount order before local routers (#747)
The index.ts refactor inverted the proxy mount order. The pre-refactor
monolith mounted `app.use('/api/', remoteNodeProxy)` before any inline
route, so remote-nodeId requests short-circuited into the proxy. After
the refactor the proxy was registered after every per-group router, so
Express matched local routers first and remote-nodeId requests were
silently handled with the control instance's local state (e.g.
GET /api/stacks with x-node-id=<remote> returned local stacks rather
than the remote's).

Fix moves createRemoteProxyMiddleware() between enforceApiTokenScope
and the first per-group router, matching middleware-order.md step 13
and restoring pre-refactor behavior. PROXY_EXEMPT_PREFIXES continues to
cover gateway-level paths (auth, nodes, license, fleet, webhooks, meta)
that must stay local even when x-node-id targets a remote.

Add four regression guards that would have caught this:

- json-parser-bypass.test.ts: asserts conditionalJsonParser leaves the
  request stream intact on proxy-eligible paths so http-proxy can pipe
  the raw body to the upstream; spins up a local echo server and
  verifies the bytes arrive.
- proxy-mount-order.test.ts: asserts a remote-nodeId GET short-circuits
  into the proxy (502 from unreachable upstream) instead of matching a
  local router (200 from local state).
- upgrade-order.test.ts: pins WebSocket dispatch order by observing
  handler-specific side effects for notifications, remote forwarder,
  logs, and pilot tunnel.
- remote-console-session.test.ts: asserts the HTTP console-token route
  mints a JWT with the same claim shape as the shared mintConsoleSession
  helper, so gateway and WS forwarder tokens remain interchangeable.

Full suite: 73 files, 1,358 tests, all passing.
2026-04-24 10:20:08 -04:00

92 lines
3.7 KiB
TypeScript

/**
* Regression guard for `createRemoteProxyMiddleware` mount order.
*
* `index.ts` mounts every local `/api/<group>` router before the remote proxy
* at step 13 of the canonical middleware order. A remote-`nodeId` request
* must short-circuit into the proxy rather than match a local router. If the
* order were reversed, a GET `/api/stacks` for a remote node would return the
* control instance's local stack list instead of the remote's.
*
* The test seeds a remote node whose `api_url` points at a closed loopback
* port. The proxy forwards, the connection fails, and the middleware
* responds with a 502. A local handler on the same path would return 200
* (or 404 / 400) with a JSON body, never 502. That distinguishing response
* proves the proxy intercepted first.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
describe('remote proxy mount order', () => {
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let remoteNodeId: number;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
// 127.0.0.1:1 is a reserved port that no process binds; TCP connect
// fails immediately with ECONNREFUSED. The proxy surfaces that as 502.
remoteNodeId = DatabaseService.getInstance().addNode({
name: 'mount-order-remote',
type: 'remote',
compose_dir: '/tmp',
is_default: false,
api_url: 'http://127.0.0.1:1',
api_token: 'mount-order-token',
});
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
it('short-circuits remote-nodeId requests into the proxy before local routers match', async () => {
const res = await request(app)
.get('/api/stacks')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
// 502: the proxy caught the request and the unreachable upstream reported
// the connection refusal. If a local router had matched first, we would
// have seen a 200 with a JSON array of local stacks.
expect(res.status).toBe(502);
// `x-sencho-proxy` is attached by the proxy's proxyRes callback, which
// only fires when the upstream produced a response. The unreachable
// upstream never does; absence here is consistent with a proxy error.
expect(res.headers['x-sencho-proxy']).toBeUndefined();
expect(res.body?.error).toMatch(/unreachable/i);
});
it('routes local requests (no x-node-id) to the local handler', async () => {
const res = await request(app)
.get('/api/stacks')
.set('Authorization', authHeader);
// Local handler responds with a stack list (200) or, in CI without docker,
// a 500 from DockerController. Anything other than 502 proves the local
// router matched instead of the proxy.
expect(res.status).not.toBe(502);
});
it('handles proxy-exempt paths locally even when x-node-id targets a remote', async () => {
// /api/nodes/:id is in PROXY_EXEMPT_PREFIXES. The proxy must never catch
// gateway-level concerns; otherwise a user whose default node is remote
// could never manage the node registry itself.
const res = await request(app)
.get(`/api/nodes/${remoteNodeId}`)
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).not.toBe(502);
expect([200, 404]).toContain(res.status);
});
});