Files
sencho/backend/src/__tests__/remote-console-session.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

98 lines
4.6 KiB
TypeScript

/**
* Regression guard for `console_session` JWT parity.
*
* A gateway that forwards an interactive WebSocket (host console or container
* exec) to a remote node calls the remote's `POST /api/system/console-token`
* endpoint and forwards the returned token in an `Authorization: Bearer`
* header during the upgrade. Meanwhile the HTTP route `/api/system/console-
* token` is what the gateway calls to mint that same token on its own remote.
* Both mint calls go through `helpers/consoleSession.ts::mintConsoleSession`.
*
* This test guards against future drift between the HTTP route and the
* helper: if somebody changes the route to mint a different claim shape,
* the remote's upgrade handler would reject one set of tokens and the
* product would silently lose remote-console support.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb';
import { mintConsoleSession } from '../helpers/consoleSession';
describe('console_session token parity (HTTP route vs mint helper)', () => {
let tmpDir: string;
let app: import('express').Express;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
// POST /api/system/console-token is Admiral-gated. Seed an active Admiral
// license so the parity assertion can observe the token the route returns.
// The license_last_validated fallback is skipped when the state key is
// absent, so we only need the two keys that drive requireAdmiral.
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().setSystemState('license_status', 'active');
DatabaseService.getInstance().setSystemState('license_variant_type', 'admiral');
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
it('POST /api/system/console-token produces a token with the same shape as mintConsoleSession()', async () => {
const directToken = mintConsoleSession();
const directDecoded = jwt.verify(directToken, TEST_JWT_SECRET) as Record<string, unknown>;
const res = await request(app)
.post('/api/system/console-token')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
const routeDecoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as Record<string, unknown>;
// Identical scope so the remote's upgrade handler treats both the same.
expect(routeDecoded.scope).toBe('console_session');
expect(directDecoded.scope).toBe('console_session');
// Same claim keys: if somebody adds or drops a claim on one path but not
// the other, remote upgrade behavior will diverge.
expect(Object.keys(routeDecoded).sort()).toEqual(Object.keys(directDecoded).sort());
// Same short TTL (60 seconds, plus or minus a second for test scheduling).
const directTtl = (directDecoded.exp as number) - (directDecoded.iat as number);
const routeTtl = (routeDecoded.exp as number) - (routeDecoded.iat as number);
expect(directTtl).toBe(60);
expect(routeTtl).toBe(60);
});
it('rejects non-admin callers of POST /api/system/console-token', async () => {
// Build a valid session JWT for a non-admin user and confirm the route
// is still admin-gated (regression: easy to drop the requireAdmin check
// when tier gates are refactored).
const viewerToken = jwt.sign({ username: 'nobody-exists' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${viewerToken}`);
// Either 401 (user not found) or 403 (role check) is acceptable; the
// critical invariant is "not 200 for a non-admin".
expect(res.status).not.toBe(200);
expect([401, 403]).toContain(res.status);
});
it('rejects API-token callers of POST /api/system/console-token (rejectApiTokenScope)', async () => {
// Mint a JWT that claims the api_token scope but is not backed by a real
// row in the database. The authMiddleware rejects this at the api_token
// branch before the route handler runs, which is the behavior we want:
// an API token should never be allowed to mint a console session.
const fakeApiToken = jwt.sign({ scope: 'api_token', username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${fakeApiToken}`);
expect(res.status).toBe(401);
});
});