Files
sencho/backend/src/__tests__/fleet-pilot-update.test.ts
T
Anso 60f893a81f feat(fleet): move bulk Remote OTA updates to Community tier (#1151)
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
2026-05-21 23:54:45 -04:00

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([]);
});
});