mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
e12602091a
* 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
184 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
});
|