mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 13:38:33 +00:00
381ed2a91f
* feat: add Admiral Hardened Build channel and business assurance surfaces Introduce Studio Saelix entitlement-backed Hardened Build switching, a single-flight image operation coordinator, Recovery Vault naming, Admiral Account settings, and typed Fleet update failures while preserving Community custom-repo and targetless pull-current updates. * fix: harden image-op paths and clear CI CodeQL/pilot flake Validate operation IDs before filesystem use, use hostname checks in Fleet fetch mocks, sanitize registry probe logs, and swallow expected TCP teardown errors in the pilot reverse-route post-handshake test. * fix: sanitize image-op docker config write and probe logs Allowlist-copy registry host keys and base64 auth before writing the temp DOCKER_CONFIG, and log registry probe failures with a fixed message so CodeQL no longer flags network-to-file and log-injection mediums. * fix: address Admiral Hardened Build audit blockers Expose imageChannel so hardened Fleet peers still POST for typed rejection, claim community updates before 202, terminalize helper failures, gate Hardened on paid, and align support/docs/e2e wording. * fix: terminalize image ops on helper survival and aborted claims * fix: prevent recreating persist from overwriting helper-exit failure * test: assert helper-exit failure lands before recreating persist * fix: keep current pointer when acknowledging a stale image operation
289 lines
10 KiB
TypeScript
289 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,
|
|
imagePinKind: null,
|
|
updateBlocked: false, imageChannel: null,
|
|
};
|
|
|
|
const META_OFFLINE: RemoteMeta = {
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
online: false,
|
|
imagePinKind: null,
|
|
updateBlocked: false, imageChannel: null,
|
|
};
|
|
|
|
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, imagePinKind: null, updateBlocked: false, imageChannel: null });
|
|
|
|
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, imagePinKind: null, updateBlocked: false, imageChannel: null });
|
|
|
|
// 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}`);
|
|
});
|
|
});
|