mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* F9 regression guard: fleet aggregator routes return real data for
|
||||
* pilot-agent nodes (capabilities, version, metrics, stacks, drilldown,
|
||||
* configuration) by dispatching through NodeRegistry.getProxyTarget instead
|
||||
* of reading node.api_url/api_token directly.
|
||||
*
|
||||
* Pre-fix:
|
||||
* - GET /api/fleet/overview returned stats=null/systemStats=null/stacks=null
|
||||
* for pilot-agent rows.
|
||||
* - GET /api/fleet/node/:id/stacks 503'd with "Remote node not configured".
|
||||
* - GET /api/fleet/node/:id/stacks/:stack/containers 503'd the same way.
|
||||
* - GET /api/fleet/update-status reported version=null and the Fleet card
|
||||
* showed perpetual "Update available".
|
||||
* - GET /api/fleet/configuration reported configuration=null.
|
||||
*
|
||||
* Post-fix: each surface fetches through the loopback URL when a pilot
|
||||
* tunnel is active and degrades to a mode-aware offline shape when not.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let pilotNodeId: number;
|
||||
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
const LOOPBACK = 'http://127.0.0.1:54321';
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
pilotNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'pilot-parity-test',
|
||||
type: 'remote',
|
||||
mode: 'pilot_agent',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
// Mark as recently seen so offline-status branches that key on
|
||||
// pilot_last_seen render the expected shape.
|
||||
DatabaseService.getInstance().updateNode(pilotNodeId, {
|
||||
pilot_last_seen: Date.now(),
|
||||
pilot_agent_version: '0.76.7',
|
||||
});
|
||||
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockTargetActive() {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
|
||||
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function mockTargetOffline() {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
|
||||
}
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL ? input.toString() : (input as Request).url;
|
||||
return handler(url, init);
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/fleet/node/:nodeId/stacks (pilot-agent)', () => {
|
||||
it('returns the pilot stacks via the loopback target', async () => {
|
||||
mockTargetActive();
|
||||
mockFetch((url) => {
|
||||
expect(url).toBe(`${LOOPBACK}/api/stacks`);
|
||||
return new Response(JSON.stringify(['audit-mesh-pilot', 'monitor']), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/fleet/node/${pilotNodeId}/stacks`)
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(['audit-mesh-pilot', 'monitor']);
|
||||
});
|
||||
|
||||
it('returns 503 with a pilot-tunnel-disconnected message when no target is available', async () => {
|
||||
mockTargetOffline();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/fleet/node/${pilotNodeId}/stacks`)
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body?.error).toMatch(/pilot tunnel/i);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits Authorization when target.apiToken is empty', async () => {
|
||||
mockTargetActive();
|
||||
let observedHeaders: Record<string, string> | undefined;
|
||||
mockFetch((_url, init) => {
|
||||
observedHeaders = (init?.headers as Record<string, string> | undefined) ?? undefined;
|
||||
return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get(`/api/fleet/node/${pilotNodeId}/stacks`)
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(observedHeaders).toBeDefined();
|
||||
expect(observedHeaders).not.toHaveProperty('Authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/node/:nodeId/stacks/:stackName/containers (pilot-agent)', () => {
|
||||
it('returns containers via the loopback target', async () => {
|
||||
mockTargetActive();
|
||||
mockFetch((url) => {
|
||||
expect(url).toBe(`${LOOPBACK}/api/stacks/audit-mesh-pilot/containers`);
|
||||
return new Response(
|
||||
JSON.stringify([{ id: 'c1', name: 'audit-mesh-pilot-echo-1', state: 'running' }]),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/fleet/node/${pilotNodeId}/stacks/audit-mesh-pilot/containers`)
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].name).toBe('audit-mesh-pilot-echo-1');
|
||||
});
|
||||
|
||||
it('returns 503 with pilot-tunnel-disconnected when no target is available', async () => {
|
||||
mockTargetOffline();
|
||||
const res = await request(app)
|
||||
.get(`/api/fleet/node/${pilotNodeId}/stacks/audit-mesh-pilot/containers`)
|
||||
.set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body?.error).toMatch(/pilot tunnel/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/overview (pilot-agent)', () => {
|
||||
it('populates stats, systemStats, and stacks for pilot-agent rows when the tunnel is up', async () => {
|
||||
mockTargetActive();
|
||||
mockFetch((url) => {
|
||||
if (url === `${LOOPBACK}/api/stats`) {
|
||||
return new Response(
|
||||
JSON.stringify({ active: 3, managed: 2, unmanaged: 1, exited: 0, total: 3 }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (url === `${LOOPBACK}/api/system/stats`) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cpu: { usage: '12.3', cores: 4 },
|
||||
memory: { total: 8000000000, used: 2000000000, free: 6000000000, usagePercent: '25.0' },
|
||||
disk: { total: 10, used: 5, free: 5, usagePercent: '50.0' },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
if (url === `${LOOPBACK}/api/stacks`) {
|
||||
return new Response(JSON.stringify(['audit-mesh-pilot']), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/fleet/overview').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
||||
expect(pilotRow).toBeDefined();
|
||||
expect(pilotRow!.status).toBe('online');
|
||||
expect(pilotRow!.stats).toEqual({ active: 3, managed: 2, unmanaged: 1, exited: 0, total: 3 });
|
||||
expect(pilotRow!.systemStats).toMatchObject({
|
||||
cpu: { usage: '12.3', cores: 4 },
|
||||
memory: { usagePercent: '25.0' },
|
||||
});
|
||||
expect(pilotRow!.stacks).toEqual(['audit-mesh-pilot']);
|
||||
expect(pilotRow!.pilot_last_seen).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('falls back to an offline shape when the tunnel is down, preserving pilot_last_seen', async () => {
|
||||
mockTargetOffline();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
const res = await request(app).get('/api/fleet/overview').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
||||
expect(pilotRow).toBeDefined();
|
||||
expect(pilotRow!.stats).toBeNull();
|
||||
expect(pilotRow!.systemStats).toBeNull();
|
||||
expect(pilotRow!.stacks).toBeNull();
|
||||
expect(pilotRow!.pilot_last_seen).toBeTypeOf('number');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/update-status (pilot-agent)', () => {
|
||||
it('reports the pilot version via fetchMetaForNode', async () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockImplementation(async (id: number) => {
|
||||
if (id === pilotNodeId) {
|
||||
return {
|
||||
version: '0.76.7',
|
||||
capabilities: ['stacks', 'containers'],
|
||||
startedAt: 1700000000,
|
||||
updateError: null,
|
||||
online: true,
|
||||
};
|
||||
}
|
||||
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false };
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const nodes = (res.body as { nodes: Array<Record<string, unknown>> }).nodes;
|
||||
const pilotRow = nodes.find(n => n.nodeId === pilotNodeId);
|
||||
expect(pilotRow).toBeDefined();
|
||||
expect(pilotRow!.version).toBe('0.76.7');
|
||||
});
|
||||
|
||||
it('reports null version when the pilot meta fetch returns offline', async () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue({
|
||||
version: null,
|
||||
capabilities: [],
|
||||
startedAt: null,
|
||||
updateError: null,
|
||||
online: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const nodes = (res.body as { nodes: Array<Record<string, unknown>> }).nodes;
|
||||
const pilotRow = nodes.find(n => n.nodeId === pilotNodeId);
|
||||
expect(pilotRow!.version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/configuration (pilot-agent)', () => {
|
||||
it('fetches the dashboard configuration via the loopback target', async () => {
|
||||
mockTargetActive();
|
||||
mockFetch((url) => {
|
||||
expect(url).toBe(`${LOOPBACK}/api/dashboard/configuration`);
|
||||
return new Response(
|
||||
JSON.stringify({ ssoConfigured: false, alertsConfigured: true }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/fleet/configuration').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
||||
expect(pilotRow).toBeDefined();
|
||||
expect(pilotRow!.status).toBe('online');
|
||||
expect(pilotRow!.configuration).toMatchObject({ alertsConfigured: true });
|
||||
});
|
||||
|
||||
it('returns offline configuration=null when the tunnel is down', async () => {
|
||||
mockTargetOffline();
|
||||
const res = await request(app).get('/api/fleet/configuration').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const pilotRow = (res.body as Array<Record<string, unknown>>).find(r => r.id === pilotNodeId);
|
||||
expect(pilotRow!.status).toBe('offline');
|
||||
expect(pilotRow!.configuration).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user