mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +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:
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, waitFor, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import HostConsole from '../HostConsole';
|
||||
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
vi.mock('@/lib/xtermLoader', () => {
|
||||
class FakeTerminal {
|
||||
cols = 80;
|
||||
rows = 24;
|
||||
open = vi.fn();
|
||||
focus = vi.fn();
|
||||
write = vi.fn();
|
||||
clear = vi.fn();
|
||||
dispose = vi.fn();
|
||||
getSelection = vi.fn(() => '');
|
||||
loadAddon = vi.fn();
|
||||
onData = vi.fn();
|
||||
}
|
||||
class FakeFitAddon {
|
||||
fit = vi.fn();
|
||||
}
|
||||
class FakeSerializeAddon {
|
||||
serialize = vi.fn(() => '');
|
||||
}
|
||||
return {
|
||||
loadXtermModules: async () => ({
|
||||
Terminal: FakeTerminal,
|
||||
FitAddon: FakeFitAddon,
|
||||
SerializeAddon: FakeSerializeAddon,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/PageMasthead', () => ({
|
||||
PageMasthead: ({ children }: { children?: ReactNode }) => <div data-testid="masthead">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../ui/button', () => ({
|
||||
Button: ({ children, ...props }: { children?: ReactNode } & Record<string, unknown>) => (
|
||||
<button type="button" {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({
|
||||
copyToClipboard: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
type FakeWs = {
|
||||
url: string;
|
||||
readyState: number;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
onopen: ((ev?: unknown) => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onerror: ((ev?: unknown) => void) | null;
|
||||
onclose: ((ev?: unknown) => void) | null;
|
||||
};
|
||||
|
||||
describe('HostConsole socket targeting', () => {
|
||||
const sockets: FakeWs[] = [];
|
||||
let OriginalWebSocket: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
sockets.length = 0;
|
||||
OriginalWebSocket = globalThis.WebSocket;
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
} as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
|
||||
globalThis.WebSocket = class {
|
||||
static OPEN = 1;
|
||||
static CLOSED = 3;
|
||||
url: string;
|
||||
readyState = 0;
|
||||
close = vi.fn(() => { this.readyState = 3; });
|
||||
send = vi.fn();
|
||||
onopen: FakeWs['onopen'] = null;
|
||||
onmessage: FakeWs['onmessage'] = null;
|
||||
onerror: FakeWs['onerror'] = null;
|
||||
onclose: FakeWs['onclose'] = null;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
sockets.push(this as unknown as FakeWs);
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(undefined);
|
||||
});
|
||||
}
|
||||
} as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = OriginalWebSocket;
|
||||
localStorage.removeItem('sencho-active-node');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the WebSocket with the explicit nodeId (not localStorage)', async () => {
|
||||
localStorage.setItem('sencho-active-node', '99');
|
||||
render(<HostConsole nodeId={7} stackName={null} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
expect(sockets[0].url).toContain('nodeId=7');
|
||||
expect(sockets[0].url).not.toContain('nodeId=99');
|
||||
});
|
||||
|
||||
it('includes the stack parameter when provided', async () => {
|
||||
render(<HostConsole nodeId={1} stackName="radarr" onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
expect(sockets[0].url).toContain('stack=radarr');
|
||||
});
|
||||
|
||||
it('closes the prior socket and opens a new one when nodeId changes', async () => {
|
||||
const { rerender } = render(<HostConsole nodeId={1} stackName={null} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
const first = sockets[0];
|
||||
|
||||
await act(async () => {
|
||||
rerender(<HostConsole nodeId={2} stackName={null} onClose={vi.fn()} />);
|
||||
});
|
||||
await waitFor(() => expect(sockets.length).toBe(2));
|
||||
expect(first.close).toHaveBeenCalled();
|
||||
expect(sockets[1].url).toContain('nodeId=2');
|
||||
});
|
||||
|
||||
it('reconnects when stackName changes so a root shell is not retained', async () => {
|
||||
const { rerender } = render(<HostConsole nodeId={1} stackName={null} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(sockets.length).toBe(1));
|
||||
const first = sockets[0];
|
||||
expect(first.url).not.toContain('stack=');
|
||||
|
||||
await act(async () => {
|
||||
rerender(<HostConsole nodeId={1} stackName="radarr" onClose={vi.fn()} />);
|
||||
});
|
||||
await waitFor(() => expect(sockets.length).toBe(2));
|
||||
expect(first.close).toHaveBeenCalled();
|
||||
expect(sockets[1].url).toContain('stack=radarr');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user