Files
sencho/backend/src/__tests__/remote-console-session.test.ts
T
Anso dd54a2e483 feat: graduate Host Console to Community admins (#1669)
* feat: graduate Host Console to Community admins

Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell.

* docs: document Host Console deep links

Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests.

* fix: bind Host Console socket to the resolved node

Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution.

* fix: harden Host Console node binding, audit acting_as, and console_session tokens

Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed.

* test: expect acting_as in audit CSV export header

Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
2026-07-23 12:59:53 -04:00

105 lines
4.9 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_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb';
import { mintConsoleSession } from '../helpers/consoleSession';
import { generateApiToken } from '../utils/apiTokenFormat';
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 admin-gated (available on every tier).
// Seed an active license only so other suites that share LicenseService
// state stay stable if they expect paid.
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().setSystemState('license_status', 'active');
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
it('POST /api/system/console-token produces a token with the same shape as mintConsoleSession()', async () => {
const directToken = mintConsoleSession({ path: 'host-console', actingAs: 'testadmin' });
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)
.send({ path: 'host-console' });
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');
expect(routeDecoded.path).toBe('host-console');
expect(directDecoded.path).toBe('host-console');
expect(typeof routeDecoded.jti).toBe('string');
expect(typeof directDecoded.jti).toBe('string');
expect(routeDecoded.acting_as).toBe('testadmin');
expect(directDecoded.acting_as).toBe('testadmin');
// 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. authMiddleware accepts the sen_sk_ format and
// rejects at the row-lookup step 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 = generateApiToken();
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${fakeApiToken}`);
expect(res.status).toBe(401);
});
});