Files
sencho/backend/src/__tests__/capability-registry-pilot.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

107 lines
3.6 KiB
TypeScript

/**
* F9 regression guard for CapabilityRegistry:
*
* - fetchRemoteMeta omits the Authorization header when the apiToken is
* empty so the loopback bridge (used by pilot-agent proxy targets) is
* not handed a malformed `Bearer ` header.
* - applyPilotModeCapabilityFilter strips host-console (whose central->pilot
* WS upgrade path is not yet wired) but leaves self-update in place so a
* Compose-deployed pilot can advertise it and the Fleet Update flow can
* route through NodeRegistry.getProxyTarget().
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import axios from 'axios';
import {
CAPABILITIES,
applyPilotModeCapabilityFilter,
enableCapability,
fetchRemoteMeta,
getActiveCapabilities,
} from '../services/CapabilityRegistry';
describe('fetchRemoteMeta Authorization header', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('sends Authorization: Bearer <token> when token is non-empty', async () => {
const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({
data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null },
});
await fetchRemoteMeta('https://remote.example.com:1852', 'real-token');
expect(getSpy).toHaveBeenCalledTimes(1);
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
});
it('omits Authorization entirely when token is empty (pilot-agent loopback)', async () => {
const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({
data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null },
});
await fetchRemoteMeta('http://127.0.0.1:54321', '');
expect(getSpy).toHaveBeenCalledTimes(1);
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
expect(init.headers).toEqual({});
expect(init.headers).not.toHaveProperty('Authorization');
});
it('returns OFFLINE_META shape on transport failure', async () => {
vi.spyOn(axios, 'get').mockRejectedValue(new Error('connect ECONNREFUSED'));
const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '');
expect(meta).toEqual({
version: null,
capabilities: [],
startedAt: null,
updateError: null,
online: false,
imagePinKind: null,
updateBlocked: false, imageChannel: null,
});
});
});
describe('applyPilotModeCapabilityFilter', () => {
afterEach(() => {
enableCapability('host-console');
enableCapability('host-console-community');
});
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');
});
it('leaves self-update in place so Compose-deployed pilots can advertise it', () => {
expect(CAPABILITIES).toContain('self-update');
applyPilotModeCapabilityFilter();
const active = getActiveCapabilities();
expect(active).toContain('self-update');
});
it('is idempotent (safe to call multiple times)', () => {
applyPilotModeCapabilityFilter();
applyPilotModeCapabilityFilter();
const active = getActiveCapabilities();
expect(active).not.toContain('host-console');
expect(active).not.toContain('host-console-community');
expect(active.length).toBe(CAPABILITIES.length - 2);
});
});