mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
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.
This commit is contained in:
@@ -76,4 +76,8 @@ describe('WebSocket API-token scope enforcement', () => {
|
||||
it('does not scope-block a full-admin token from a generic socket', async () => {
|
||||
expect(await upgradeStatus(createToken('full-admin'), '/ws')).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks a full-admin token from the host console (403)', async () => {
|
||||
expect(await upgradeStatus(createToken('full-admin'), '/api/system/host-console')).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -715,7 +715,7 @@ describe('GET /api/audit-log/export', () => {
|
||||
|
||||
const csvText = res.text;
|
||||
const lines = csvText.split('\n');
|
||||
expect(lines[0]).toBe('id,timestamp,username,method,path,status_code,node_id,ip_address,summary');
|
||||
expect(lines[0]).toBe('id,timestamp,username,acting_as,method,path,status_code,node_id,ip_address,summary');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ describe('POST /api/system/console-token', () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ path: 'host-console' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
|
||||
@@ -70,15 +70,18 @@ describe('fetchRemoteMeta Authorization header', () => {
|
||||
describe('applyPilotModeCapabilityFilter', () => {
|
||||
afterEach(() => {
|
||||
enableCapability('host-console');
|
||||
enableCapability('host-console-community');
|
||||
});
|
||||
|
||||
it('removes host-console from active capabilities', () => {
|
||||
it('removes host-console and host-console-community from active capabilities', () => {
|
||||
expect(CAPABILITIES).toContain('host-console');
|
||||
expect(CAPABILITIES).toContain('host-console-community');
|
||||
|
||||
applyPilotModeCapabilityFilter();
|
||||
const active = getActiveCapabilities();
|
||||
|
||||
expect(active).not.toContain('host-console');
|
||||
expect(active).not.toContain('host-console-community');
|
||||
expect(active).toContain('stacks');
|
||||
});
|
||||
|
||||
@@ -97,6 +100,7 @@ describe('applyPilotModeCapabilityFilter', () => {
|
||||
const active = getActiveCapabilities();
|
||||
|
||||
expect(active).not.toContain('host-console');
|
||||
expect(active.length).toBe(CAPABILITIES.length - 1);
|
||||
expect(active).not.toContain('host-console-community');
|
||||
expect(active.length).toBe(CAPABILITIES.length - 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
beforeAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
tmpDir = await setupTestDb();
|
||||
// Host console requires the paid tier; mock the license so the tier gate
|
||||
// passes for the admin/accepted cases. Individual tests override as needed.
|
||||
// Host console is available on every tier for admins; mock paid so other
|
||||
// suites that share LicenseService state stay stable.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
getTierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const mod = await import('../index');
|
||||
@@ -78,10 +78,28 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects an admin on the Community tier (403)', async () => {
|
||||
it('accepts a Community-tier admin', async () => {
|
||||
getTierSpy.mockReturnValueOnce('community');
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => { ws.close(); resolve(true); });
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a console_session Bearer on Community (remote bridge mint path)', async () => {
|
||||
getTierSpy.mockReturnValueOnce('community');
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console', actingAs: TEST_USERNAME });
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => { ws.close(); resolve(true); });
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an admin on Admiral and records an open audit row', async () => {
|
||||
@@ -127,6 +145,116 @@ describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
expect(firstMessage).toContain('Invalid stack path');
|
||||
ws.close();
|
||||
});
|
||||
|
||||
|
||||
it('rejects an unknown nodeId without spawning a PTY (404)', async () => {
|
||||
const { HostTerminalService } = await import('../services/HostTerminalService');
|
||||
const spawnSpy = vi.spyOn(HostTerminalService, 'spawnTerminal');
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl('?nodeId=99999999'), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(404);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a malformed nodeId that parseInt would coerce (404)', async () => {
|
||||
const ws = new WebSocket(wsUrl('?nodeId=1abc'), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects zero and negative nodeId values (404)', async () => {
|
||||
for (const id of ['0', '-1']) {
|
||||
const ws = new WebSocket(wsUrl(`?nodeId=${id}`), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a replayed console_session token on second Host Console upgrade', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console', actingAs: 'qaadmin' });
|
||||
const first = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const firstOpened = await new Promise<boolean>((resolve) => {
|
||||
first.on('open', () => { first.close(); resolve(true); });
|
||||
first.on('error', () => resolve(false));
|
||||
first.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(firstOpened).toBe(true);
|
||||
const second = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
expect(await expectRejected(second)).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a host-console console_session on the container-exec /ws path', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const token = mintConsoleSession({ path: 'host-console' });
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Server not listening');
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${addr.port}/ws`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('records acting_as on console_session open audit rows', async () => {
|
||||
const { mintConsoleSession } = await import('../helpers/consoleSession');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const insertSpy = vi.spyOn(db, 'insertAuditLog');
|
||||
const token = mintConsoleSession({ path: 'host-console', actingAs: 'qaadmin' });
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => { ws.close(); resolve(true); });
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
await waitFor(() => insertSpy.mock.calls.some((c) => {
|
||||
const e = c[0] as { summary?: string };
|
||||
return typeof e.summary === 'string' && e.summary.includes('Opened host console');
|
||||
}));
|
||||
const openEntry = insertSpy.mock.calls
|
||||
.map((c) => c[0] as { username: string; acting_as?: string | null; summary: string })
|
||||
.find((e) => e.summary.includes('Opened host console'));
|
||||
expect(openEntry?.username).toBe('console_session');
|
||||
expect(openEntry?.acting_as).toBe('qaadmin');
|
||||
} finally {
|
||||
insertSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes without spawning a PTY when directory resolution throws (no default-node fallback)', async () => {
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
const { HostTerminalService } = await import('../services/HostTerminalService');
|
||||
const spawnSpy = vi.spyOn(HostTerminalService, 'spawnTerminal');
|
||||
const getInstanceSpy = vi.spyOn(FileSystemService, 'getInstance').mockImplementation(() => {
|
||||
throw new Error('compose dir unavailable');
|
||||
});
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
const firstMessage = await new Promise<string>((resolve) => {
|
||||
ws.on('message', (data) => resolve(data.toString()));
|
||||
ws.on('error', () => resolve(''));
|
||||
ws.on('unexpected-response', () => resolve(''));
|
||||
});
|
||||
expect(firstMessage).toMatch(/Failed to resolve console directory/i);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
// getInstance must not be retried against a fallback/default node id.
|
||||
expect(getInstanceSpy).toHaveBeenCalledTimes(1);
|
||||
ws.close();
|
||||
} finally {
|
||||
spawnSpy.mockRestore();
|
||||
getInstanceSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Poll a predicate up to ~1s; resolve true as soon as it passes. */
|
||||
|
||||
@@ -375,9 +375,42 @@ describe('POST /api/system/console-token', () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ path: 'host-console' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
const decoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as { scope?: string; path?: string; jti?: string };
|
||||
expect(decoded.scope).toBe('console_session');
|
||||
expect(decoded.path).toBe('host-console');
|
||||
expect(typeof decoded.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 200 for an admin on Community (no paid gate)', async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
try {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ path: 'container-exec' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
const decoded = jwt.verify(res.body.token, TEST_JWT_SECRET) as { scope?: string; path?: string };
|
||||
expect(decoded.scope).toBe('console_session');
|
||||
expect(decoded.path).toBe('container-exec');
|
||||
} finally {
|
||||
spy.mockReturnValue('paid');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin user (viewer role)', async () => {
|
||||
|
||||
@@ -30,10 +30,9 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
// POST /api/system/console-token is paid-gated. Seed an active 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
|
||||
// the active status alone drives the paid tier.
|
||||
// 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');
|
||||
});
|
||||
@@ -43,12 +42,13 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
});
|
||||
|
||||
it('POST /api/system/console-token produces a token with the same shape as mintConsoleSession()', async () => {
|
||||
const directToken = mintConsoleSession();
|
||||
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);
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ path: 'host-console' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
|
||||
@@ -57,6 +57,12 @@ describe('console_session token parity (HTTP route vs mint helper)', () => {
|
||||
// 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.
|
||||
|
||||
@@ -58,6 +58,13 @@ describe('remoteWsForwardAllowed', () => {
|
||||
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('denies every API-token scope for Host Console while preserving full-admin for container exec', () => {
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'deploy-only', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, wsApiTokenScope: 'read-only', decoded: { scope: 'api_token' } })).toBe(false);
|
||||
expect(remoteWsForwardAllowed(EXEC, { ...base, wsApiTokenScope: 'full-admin', decoded: { scope: 'api_token' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows an interactive path for a pre-gated console_session token', () => {
|
||||
expect(remoteWsForwardAllowed(CONSOLE, { ...base, decoded: { scope: 'console_session' } })).toBe(true);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* product paths. This test pins each position by observing behavior that
|
||||
* could only originate from the expected handler.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -420,6 +420,100 @@ describe('WebSocket upgrade dispatch order', () => {
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token remote Host Console with 403 (container exec stays allowed)', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
|
||||
const rawToken = createTestApiToken({
|
||||
db: DatabaseService,
|
||||
scope: 'full-admin',
|
||||
userId: adminId,
|
||||
name: `host-console-api-deny-${Date.now()}`,
|
||||
});
|
||||
const consoleWs = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { bearer: rawToken });
|
||||
const consoleOutcome = await waitForOutcome(consoleWs);
|
||||
expect(consoleOutcome.kind).toBe('unexpected');
|
||||
if (consoleOutcome.kind === 'unexpected') expect(consoleOutcome.status).toBe(403);
|
||||
|
||||
const execWs = connect(`/ws?nodeId=${remoteNodeId}`, { bearer: rawToken });
|
||||
const execOutcome = await waitForOutcome(execWs);
|
||||
if (execOutcome.kind === 'unexpected') expect(execOutcome.status).not.toBe(403);
|
||||
try { execWs.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote Host Console Community capability probe', () => {
|
||||
// Community hubs must probe host-console-community before forwarding Host
|
||||
// Console; Admiral hubs skip the probe so legacy remotes still work.
|
||||
// Fail closed when the probe errors or the capability is missing.
|
||||
|
||||
const meta = (capabilities: string[]): import('../services/CapabilityRegistry').RemoteMeta => ({
|
||||
version: '0.95.0',
|
||||
capabilities,
|
||||
startedAt: 1,
|
||||
updateError: null,
|
||||
online: true,
|
||||
imagePinKind: null,
|
||||
updateBlocked: false,
|
||||
imageChannel: null,
|
||||
});
|
||||
|
||||
async function setLicense(status: string): Promise<void> {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
DatabaseService.getInstance().setSystemState('license_status', status);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await setLicense('active');
|
||||
});
|
||||
|
||||
it('rejects Community hub remote Host Console when the remote lacks host-console-community', async () => {
|
||||
await setLicense('community');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta(['host-console']));
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects Community hub remote Host Console when the capability probe fails', async () => {
|
||||
await setLicense('community');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockRejectedValue(new Error('offline'));
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
expect(outcome.kind).toBe('unexpected');
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
|
||||
});
|
||||
|
||||
it('forwards Community hub remote Host Console when the remote advertises host-console-community', async () => {
|
||||
await setLicense('community');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(
|
||||
meta(['host-console-community']),
|
||||
);
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).not.toBe(403);
|
||||
try { ws.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('skips the capability probe on an Admiral hub even when the remote lacks host-console-community', async () => {
|
||||
await setLicense('active');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta([]));
|
||||
|
||||
const ws = connect(`/api/system/host-console?nodeId=${remoteNodeId}`, { cookie: sessionCookie });
|
||||
const outcome = await waitForOutcome(ws);
|
||||
if (outcome.kind === 'unexpected') expect(outcome.status).not.toBe(403);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
try { ws.terminate(); } catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches /api/pilot/tunnel to the pilot handler (rejects non-pilot bearer before path-based dispatch)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user