Files
sencho/backend/src/__tests__/self-update-pinned-routes.test.ts
T
Anso e12602091a fix: repin semver compose tags during fleet self-update (#1587)
* fix: repin semver compose tags during fleet self-update

Fleet updates failed when docker-compose.yml pinned a semver tag because recreate reused the on-disk pin. Pull the target image first, rewrite semver pins via the update helper, and block digest or unresolved pins with fast 409s.

* fix: update OFFLINE_META shape in capability and node-registry meta tests
2026-07-07 14:40:50 -04:00

184 lines
7.0 KiB
TypeScript

/**
* Route coverage for pinned-compose self-update: targetVersion validation,
* synchronous preflight (409 before 202), and the safe public /api/meta pin
* subset (imagePinKind + updateBlocked, never composeImageRef).
*/
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { buildTargetImageRef } from '../helpers/selfUpdateCompose';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminAuth: string;
let localNodeId: number;
let SelfUpdateService: typeof import('../services/SelfUpdateService').default;
let FleetUpdateTrackerService: typeof import('../services/FleetUpdateTrackerService').FleetUpdateTrackerService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
function mockSelfUpdateAvailable(over: {
pinInfo?: { pinKind: 'semver' | 'floating' | 'digest' | 'unknown'; composeImageRef: string; filePath: string } | null;
preflight?: { ok: true } | { ok: false; reason: string };
available?: boolean;
} = {}) {
const svc = SelfUpdateService.getInstance();
vi.spyOn(svc, 'isAvailable').mockReturnValue(over.available ?? true);
vi.spyOn(svc, 'getPinInfo').mockResolvedValue(over.pinInfo ?? null);
vi.spyOn(svc, 'canSelfUpdateTarget').mockResolvedValue(over.preflight ?? { ok: true });
vi.spyOn(svc, 'triggerUpdate').mockResolvedValue(undefined);
vi.spyOn(svc, 'getLastError').mockReturnValue(null);
}
function mockCompareTargetFetch() {
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
SelfUpdateService = (await import('../services/SelfUpdateService')).default;
({ FleetUpdateTrackerService } = await import('../services/FleetUpdateTrackerService'));
({ DatabaseService } = await import('../services/DatabaseService'));
localNodeId = DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id;
adminAuth = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
vi.restoreAllMocks();
const tracker = FleetUpdateTrackerService.getInstance();
for (const [id] of tracker.entries()) tracker.delete(id);
});
describe('POST /api/system/update', () => {
it('returns 400 for a supplied but invalid targetVersion', async () => {
mockSelfUpdateAvailable();
const res = await request(app)
.post('/api/system/update')
.set('Authorization', adminAuth)
.send({ targetVersion: 'not-a-version' });
expect(res.status).toBe(400);
expect(res.body?.error).toMatch(/invalid target version/i);
});
it('returns 409 when preflight blocks a digest or unknown pin', async () => {
mockSelfUpdateAvailable({
preflight: { ok: false, reason: 'Cannot repin digest pin.' },
});
const res = await request(app)
.post('/api/system/update')
.set('Authorization', adminAuth)
.send({ targetVersion: '0.99.0' });
expect(res.status).toBe(409);
expect(res.body?.code).toBe('update_blocked');
expect(res.body?.error).toMatch(/cannot repin/i);
});
it('returns 202 and schedules the update when preflight passes', async () => {
const triggerSpy = vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined);
mockSelfUpdateAvailable({ preflight: { ok: true } });
const res = await request(app)
.post('/api/system/update')
.set('Authorization', adminAuth)
.send({ targetVersion: '0.99.0' });
expect(res.status).toBe(202);
expect(res.body?.message).toMatch(/restart/i);
// triggerUpdate runs on res finish + delay; flush the microtask queue.
await new Promise(r => setTimeout(r, 600));
expect(triggerSpy).toHaveBeenCalledWith({ targetVersion: '0.99.0' });
});
});
describe('GET /api/meta pin subset', () => {
it('exposes imagePinKind and updateBlocked but never composeImageRef', async () => {
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.93.3', filePath: '/opt/sencho/docker-compose.yml' },
});
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.imagePinKind).toBe('semver');
expect(res.body.updateBlocked).toBe(false);
expect(res.body).not.toHaveProperty('composeImageRef');
expect(res.body).not.toHaveProperty('targetImageRef');
});
it('reports updateBlocked=true for a digest pin', async () => {
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'digest', composeImageRef: 'saelix/sencho@sha256:abc', filePath: '/opt/sencho/docker-compose.yml' },
});
const res = await request(app).get('/api/meta');
expect(res.body.imagePinKind).toBe('digest');
expect(res.body.updateBlocked).toBe(true);
});
});
describe('POST /api/fleet/nodes/:nodeId/update (local preflight)', () => {
it('returns 409 before 202 when the local pin cannot be repinned', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
preflight: { ok: false, reason: 'Blocked by digest pin.' },
});
const res = await request(app)
.post(`/api/fleet/nodes/${localNodeId}/update`)
.set('Authorization', adminAuth)
.send({ targetVersion: '0.99.0' });
expect(res.status).toBe(409);
expect(res.body?.code).toBe('update_blocked');
});
});
describe('GET /api/fleet/update-status pin projection', () => {
it('includes compose pin fields for the local node on the authenticated route', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'semver', composeImageRef: 'saelix/sencho:0.83.0', filePath: '/opt/sencho/docker-compose.yml' },
});
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', adminAuth);
expect(res.status).toBe(200);
const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
expect(local.imagePinKind).toBe('semver');
expect(local.composeImageRef).toBe('saelix/sencho:0.83.0');
expect(local.targetImageRef).toBe(buildTargetImageRef(local.composeImageRef, local.latestVersion));
expect(local.updateBlocked).toBe(false);
});
it('marks the local node updateBlocked when the pin is unknown', async () => {
mockCompareTargetFetch();
mockSelfUpdateAvailable({
pinInfo: { pinKind: 'unknown', composeImageRef: 'saelix/sencho:${TAG}', filePath: '/opt/sencho/docker-compose.yml' },
});
const res = await request(app)
.get('/api/fleet/update-status')
.set('Authorization', adminAuth);
const local = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === localNodeId);
expect(local.updateBlocked).toBe(true);
expect(local.updateBlockedReason).toMatch(/cannot update automatically/i);
});
});