Files
sencho/backend/src/__tests__/node-registry-fetch-meta.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

132 lines
4.0 KiB
TypeScript

/**
* F9 regression guard for NodeRegistry.fetchMetaForNode:
*
* - Resolves getProxyTarget for the node and delegates to fetchRemoteMeta.
* - Pilot-agent with active tunnel resolves to a loopback URL with empty
* token; the request must reach fetchRemoteMeta with that exact shape.
* - Null target (proxy-mode missing api_url/api_token, or pilot-agent
* tunnel disconnected) returns OFFLINE_META without touching the network.
*/
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import axios from 'axios';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ NodeRegistry } = await import('../services/NodeRegistry'));
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('NodeRegistry.fetchMetaForNode', () => {
it('returns OFFLINE_META when getProxyTarget is null', async () => {
const reg = NodeRegistry.getInstance();
const db = DatabaseService.getInstance();
const nodeId = db.addNode({
name: 'meta-pilot-down',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/tmp',
is_default: false,
api_url: '',
api_token: '',
});
vi.spyOn(reg, 'getProxyTarget').mockReturnValue(null);
const axiosSpy = vi.spyOn(axios, 'get');
const meta = await reg.fetchMetaForNode(nodeId);
expect(meta).toEqual({
version: null,
capabilities: [],
startedAt: null,
updateError: null,
online: false,
});
expect(axiosSpy).not.toHaveBeenCalled();
db.deleteNode(nodeId);
});
it('delegates to fetchRemoteMeta against the loopback URL for pilot-agent', async () => {
const reg = NodeRegistry.getInstance();
const db = DatabaseService.getInstance();
const nodeId = db.addNode({
name: 'meta-pilot-up',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/tmp',
is_default: false,
api_url: '',
api_token: '',
});
vi.spyOn(reg, 'getProxyTarget').mockReturnValue({
apiUrl: 'http://127.0.0.1:54321',
apiToken: '',
});
const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({
data: {
version: '0.76.7',
capabilities: ['stacks', 'containers'],
startedAt: 1234,
updateError: null,
},
});
const meta = await reg.fetchMetaForNode(nodeId);
expect(meta.version).toBe('0.76.7');
expect(meta.capabilities).toEqual(['stacks', 'containers']);
expect(meta.online).toBe(true);
expect(axiosSpy).toHaveBeenCalledTimes(1);
const url = axiosSpy.mock.calls[0][0];
expect(url).toBe('http://127.0.0.1:54321/api/meta');
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string> };
expect(init.headers).toEqual({});
db.deleteNode(nodeId);
});
it('forwards Authorization for proxy-mode targets with non-empty tokens', async () => {
const reg = NodeRegistry.getInstance();
const db = DatabaseService.getInstance();
const nodeId = db.addNode({
name: 'meta-proxy',
type: 'remote',
mode: 'proxy',
compose_dir: '/tmp',
is_default: false,
api_url: 'https://remote.example.com:1852',
api_token: 'real-token',
});
vi.spyOn(reg, 'getProxyTarget').mockReturnValue({
apiUrl: 'https://remote.example.com:1852',
apiToken: 'real-token',
});
const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({
data: { version: '0.76.7', capabilities: [], startedAt: 1, updateError: null },
});
await reg.fetchMetaForNode(nodeId);
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string> };
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
db.deleteNode(nodeId);
});
});