mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
60f893a81f
Drops `requirePaid` from `POST /api/fleet/update-all` so Community admins can dispatch bulk node updates. Per-node OTA was already Community- reachable (admin-only); this completes the move so the full Remote OTA surface ships at Community. Frontend mirrors the backend: removes `canBulkUpdate` from NodeUpdatesSheet so the "Update all (N)" affordance is purely data- driven on `updatableRemoteCount > 0`. Docs realigned to drop fence-spec and Skipper-only phrasing on the Update all bulk action: - features/licensing.mdx: Community line now lists Remote OTA (per-node and Update all); Skipper Fleet Actions parenthetical drops "bulk update all" - features/remote-updates.mdx: Note rewritten to role-only requirement - features/fleet-view.mdx: Update all (n) bullet drops the tier clause - features/overview.mdx: Fleet View and Remote updates blurbs drop the Skipper/Admiral fences - operations/upgrade.mdx: Note rephrased without naming tiers Test coverage: - fleet.test.ts: tier-gating spec flipped to assert Community access - fleet-pilot-update.test.ts: bulk-OTA dispatch suite now spies tier to Community so it doubles as a regression guard
236 lines
8.1 KiB
TypeScript
236 lines
8.1 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';
|
|
|
|
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([]);
|
|
});
|
|
});
|