Files
sencho/backend/src/__tests__/capability-registry-pilot.test.ts
T
Anso 8dd0fce621 fix(fleet): show capabilities, version, metrics, and stacks for pilot-agent nodes (#1044)
When a pilot-agent node was the active node, the UI rendered "does not
advertise this capability" across most tabs, a perpetual "Update
available" badge, and a Fleet card body with blank CPU/RAM/Disk and "No
stacks found". The cause was central-side aggregators in /api/fleet/* and
/api/nodes/:id/meta only fanning out to proxy-mode remotes via
node.api_url + node.api_token, which are null for pilot-agent.

Route every affected aggregator through NodeRegistry.getProxyTarget so
the loopback URL backed by the active pilot tunnel is used uniformly:

- /api/nodes/:id/meta and /api/fleet/update-status fetch via the new
  NodeRegistry.fetchMetaForNode helper (resolves the target, delegates
  to fetchRemoteMeta, returns the shared OFFLINE_META on null).
- fetchRemoteNodeOverview, /api/fleet/configuration,
  /api/fleet/node/:nodeId/stacks, and the stack-containers drilldown
  fetch through target.apiUrl with conditional Authorization.
- fetchRemoteMeta omits the Authorization header when the token is empty
  (pilot-agent loopback) instead of sending a malformed Bearer string.
- Pilot-agent rows preserve pilot_last_seen and mirror it into
  last_successful_contact so the Fleet "last seen" cell renders the
  recent tunnel timestamp during a brief reconnect.

Pilot-mode capability filter excludes capabilities whose central-pilot
path is not yet wired (host-console, self-update). Without this, the
Console tab would surface for an Admiral pilot session and click
through to central's host because the WS upgrade handler still gates
on api_url + api_token. Filtered capabilities are removed at boot via
applyPilotModeCapabilityFilter when SENCHO_MODE=pilot.

Cache invalidation on tunnel-up: the meta cache for a reconnecting
pilot is dropped so the next request rebuilds capabilities and version
through the live bridge instead of waiting for the 3-minute TTL. The
namespace constant moves to helpers/cacheInvalidation.ts alongside the
new invalidateRemoteMetaCache helper.

Husky commit-msg hook: add the missing shebang and a .gitattributes
rule pinning .husky/* to LF line endings so commits do not fail with
"Exec format error" on Windows shells where autocrlf=true converts the
hook to CRLF.

Tests cover Authorization-header behavior, pilot-mode filter idempotency,
fetchMetaForNode dispatch (offline target, pilot-agent loopback,
proxy-mode), and the four affected fleet routes for pilot-agent both
when the tunnel is up and when it is down.
2026-05-14 10:21:08 -04:00

94 lines
3.1 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 capabilities whose central->pilot
* path is not yet wired (host-console, self-update) so the frontend
* cannot offer them on a pilot-active session.
*/
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');
enableCapability('self-update');
});
it('removes host-console and self-update from active capabilities', () => {
expect(CAPABILITIES).toContain('host-console');
expect(CAPABILITIES).toContain('self-update');
applyPilotModeCapabilityFilter();
const active = getActiveCapabilities();
expect(active).not.toContain('host-console');
expect(active).not.toContain('self-update');
expect(active).toContain('stacks');
});
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 - 2);
});
});