mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
d03d97d964
* fix(nodes): close capability-gating gaps in node compatibility Vulnerability scanning is now gated correctly on whether the active node advertises support for it: - A node without the Trivy binary stops advertising the scanning capability. Previously the capability was toggled only on a state change, so a node that booted without Trivy kept advertising scanning it could not perform. - The control node's own capability list now reflects features disabled at runtime, matching what it advertises to peers. - The scan history surface shows a clear "not available on this node" card, with its header actions hidden, instead of attempting a request that fails. A node's version and capability metadata now refreshes immediately after a connection test or a completed update, rather than waiting out the cache. Capability gates fail closed to the unavailable card when a node's metadata request errors, instead of staying open until the next fetch. Adds a test that fails if the frontend and backend capability lists drift, plus coverage for the metadata error path, the runtime-disabled local meta, the scanning capability sync, and the metadata cache invalidation paths. * fix(nodes): refresh node metadata client-side after a connection test A connection test dropped the server-side metadata cache, but the dashboard kept its own cached copy until the client TTL expired, so version and capability gates could stay stale in the browser. The test now forces a client-side metadata refresh for that node, so the version pill and gates reflect the node's current state immediately. Also strips any URL userinfo before logging the metadata fetch target, and makes the scanning-capability detection test deterministically exercise the no-binary disable path rather than depending on whether the runner has Trivy.
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
/**
|
|
* Regression guard: POST /api/fleet/nodes/:id/update and POST /api/fleet/update-all
|
|
* route through NodeRegistry.getProxyTarget so pilot-agent nodes (which carry no
|
|
* node.api_url / node.api_token) can receive remote update commands.
|
|
*
|
|
* Pre-fix:
|
|
* - Single update on a pilot returned 503 "Remote node not configured."
|
|
* - Update-all filtered every pilot row out before dispatch.
|
|
*
|
|
* Post-fix: each route dispatches against target.apiUrl (the loopback URL for
|
|
* pilots, the configured api_url for proxy-mode remotes), and emits a
|
|
* mode-aware 503 when the target is unavailable.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import type { RemoteMeta } from '../services/CapabilityRegistry';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
import { CacheService } from '../services/CacheService';
|
|
|
|
const LOOPBACK = 'http://127.0.0.1:54322';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authHeader: string;
|
|
let pilotNodeId: number;
|
|
let proxyNodeId: number;
|
|
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
|
let FleetUpdateTrackerService: typeof import('../services/FleetUpdateTrackerService').FleetUpdateTrackerService;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
|
|
|
const META_ONLINE_OUTDATED: RemoteMeta = {
|
|
version: '0.83.0',
|
|
capabilities: ['stacks', 'self-update'],
|
|
startedAt: 1,
|
|
updateError: null,
|
|
online: true,
|
|
};
|
|
|
|
const META_OFFLINE: RemoteMeta = {
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
online: false,
|
|
};
|
|
|
|
const META_NO_SELF_UPDATE: RemoteMeta = {
|
|
...META_ONLINE_OUTDATED,
|
|
capabilities: ['stacks'],
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
|
({ FleetUpdateTrackerService } = await import('../services/FleetUpdateTrackerService'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ LicenseService } = await import('../services/LicenseService'));
|
|
|
|
const db = DatabaseService.getInstance();
|
|
pilotNodeId = db.addNode({
|
|
name: 'pilot-update-test',
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
db.updateNode(pilotNodeId, {
|
|
pilot_last_seen: Date.now(),
|
|
pilot_agent_version: '0.83.0',
|
|
});
|
|
|
|
proxyNodeId = db.addNode({
|
|
name: 'proxy-update-test',
|
|
type: 'remote',
|
|
mode: 'proxy',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: 'http://192.168.1.99:1852',
|
|
api_token: 'proxy-token',
|
|
});
|
|
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
authHeader = `Bearer ${token}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
const tracker = FleetUpdateTrackerService.getInstance();
|
|
for (const [id] of tracker.entries()) tracker.delete(id);
|
|
});
|
|
|
|
function mockTargetForPilot() {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
|
|
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
|
|
if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' };
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function mockTargetUnreachable() {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
|
|
}
|
|
|
|
function mockMeta(meta: RemoteMeta) {
|
|
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockResolvedValue(meta);
|
|
}
|
|
|
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => handler(String(input), init));
|
|
}
|
|
|
|
describe('POST /api/fleet/nodes/:nodeId/update (pilot-agent)', () => {
|
|
it('dispatches /api/system/update via the loopback target and returns 202', async () => {
|
|
mockTargetForPilot();
|
|
mockMeta(META_ONLINE_OUTDATED);
|
|
let postedUrl: string | undefined;
|
|
let postedHeaders: Record<string, string> | undefined;
|
|
mockFetch((url, init) => {
|
|
postedUrl = url;
|
|
postedHeaders = (init?.headers as Record<string, string>) ?? undefined;
|
|
return new Response('', { status: 202 });
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post(`/api/fleet/nodes/${pilotNodeId}/update`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(202);
|
|
expect(postedUrl).toBe(`${LOOPBACK}/api/system/update`);
|
|
expect(postedHeaders).not.toHaveProperty('Authorization');
|
|
|
|
const tracker = FleetUpdateTrackerService.getInstance().get(pilotNodeId);
|
|
expect(tracker?.status).toBe('updating');
|
|
});
|
|
|
|
it('returns 503 with a pilot-tunnel-disconnected message when target is null', async () => {
|
|
mockTargetUnreachable();
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/fleet/nodes/${pilotNodeId}/update`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body?.error).toMatch(/pilot tunnel/i);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 503 with the self-update-unsupported message when capability missing', async () => {
|
|
mockTargetForPilot();
|
|
mockMeta(META_NO_SELF_UPDATE);
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/fleet/nodes/${pilotNodeId}/update`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body?.error).toMatch(/does not support self-update/i);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 503 with unreachable message when meta.online is false', async () => {
|
|
mockTargetForPilot();
|
|
mockMeta(META_OFFLINE);
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/fleet/nodes/${pilotNodeId}/update`)
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body?.error).toMatch(/unreachable/i);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => {
|
|
// Bulk OTA is admin-only and runs at every tier; spy the tier to Community
|
|
// so this suite doubles as a regression guard that the gate has not been
|
|
// re-introduced.
|
|
function mockCommunityTier() {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
|
}
|
|
|
|
it('includes the pilot node in the candidate set and dispatches through its target', async () => {
|
|
mockCommunityTier();
|
|
mockTargetForPilot();
|
|
mockMeta(META_ONLINE_OUTDATED);
|
|
const postedUrls: string[] = [];
|
|
mockFetch((url) => {
|
|
postedUrls.push(url);
|
|
return new Response('', { status: 202 });
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fleet/update-all')
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(202);
|
|
expect(res.body.updating).toContain('pilot-update-test');
|
|
expect(res.body.updating).toContain('proxy-update-test');
|
|
expect(postedUrls).toContain(`${LOOPBACK}/api/system/update`);
|
|
expect(postedUrls).toContain('http://192.168.1.99:1852/api/system/update');
|
|
});
|
|
|
|
it('skips remotes whose target resolves to null and never calls /api/system/update on them', async () => {
|
|
mockCommunityTier();
|
|
mockTargetUnreachable();
|
|
// /update-all also calls api.github.com to compute the compare target;
|
|
// pin the assertion to the route's own dispatch surface.
|
|
const systemUpdateCalls: string[] = [];
|
|
mockFetch((url) => {
|
|
if (url.endsWith('/api/system/update')) systemUpdateCalls.push(url);
|
|
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/fleet/update-all')
|
|
.set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(202);
|
|
expect(res.body.updating).toEqual([]);
|
|
expect(res.body.skipped).toEqual(expect.arrayContaining(['pilot-update-test', 'proxy-update-test']));
|
|
expect(systemUpdateCalls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () => {
|
|
// Satisfy getCompareTarget's GitHub lookup with a benign response so the
|
|
// route does not make a real network call during the test.
|
|
function mockCompareTargetFetch() {
|
|
mockFetch(() =>
|
|
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
}
|
|
|
|
it('drops the cached meta when a node transitions to completed', async () => {
|
|
mockTargetForPilot();
|
|
mockCompareTargetFetch();
|
|
// Remote now reports a different version than before the update (signal 1).
|
|
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
|
|
|
const tracker = FleetUpdateTrackerService.getInstance();
|
|
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
|
|
|
|
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
|
|
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('completed');
|
|
expect(invalidateSpy).toHaveBeenCalledWith(`remote-meta:${proxyNodeId}`);
|
|
});
|
|
|
|
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
|
|
mockTargetForPilot();
|
|
mockCompareTargetFetch();
|
|
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
|
|
|
// Already completed before this poll: no transition, so no invalidation.
|
|
const tracker = FleetUpdateTrackerService.getInstance();
|
|
tracker.set(proxyNodeId, tracker.create('completed', '0.83.0', null));
|
|
|
|
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
|
|
|
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(invalidateSpy).not.toHaveBeenCalledWith(`remote-meta:${proxyNodeId}`);
|
|
});
|
|
});
|