Files
sencho/backend/src/__tests__/capability-registry-pilot.test.ts
T
Anso 0b50c88eb3 fix(fleet): route remote update trigger through getProxyTarget (#1123)
POST /api/fleet/nodes/:id/update and POST /api/fleet/update-all read
node.api_url + node.api_token directly, so a pilot-agent row (which
carries neither) returned "Remote node not configured." and was
filtered out of bulk update. Both routes now resolve the target via
NodeRegistry.getProxyTarget(), which returns the loopback URL for an
active pilot tunnel and the configured api_url for proxy-mode remotes.
fetchMetaForNode replaces the direct fetchRemoteMeta call for the
self-update capability check.

self-update is removed from PILOT_DISABLED_CAPABILITIES so a
Compose-deployed pilot can advertise it; the host-console filter
stays. SelfUpdateService still gates the local-end capability on the
container actually carrying docker-compose labels, so a docker-run
pilot self-disables and the Fleet UI sees a clean 503 instead of an
ambiguous failure.

Tests: new fleet-pilot-update covers the four single-node branches
(success via loopback, null target, no self-update capability, meta
offline) and two update-all cases (mixed-fleet dispatch, all-targets-
null skip). capability-registry-pilot rewired to assert the new
filter set.
2026-05-20 03:35:39 -04:00

101 lines
3.3 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,
});
});
});
describe('applyPilotModeCapabilityFilter', () => {
afterEach(() => {
enableCapability('host-console');
});
it('removes host-console from active capabilities', () => {
expect(CAPABILITIES).toContain('host-console');
applyPilotModeCapabilityFilter();
const active = getActiveCapabilities();
expect(active).not.toContain('host-console');
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.length).toBe(CAPABILITIES.length - 1);
});
});