mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
4fa532530e
* fix(image-updates): explain persistent digest rebuilds after update When an update completes but a same-tag digest rebuild is still detected, the generic "update still detected" warning told operators nothing about why. The digest comparison already knows the remaining updates are digest-only (no higher tag), so recheckStack now returns a targeted warning naming the two daemon-side causes: a registry mirror or cache serving stale content, or a container still pinned to the previous image. The digest-rebuild badge surfaces (Anatomy banner, Fleet cards, mobile) now carry a tooltip with the same explanation, and the post-update warning is added to the pre-update refresh sanitization set. * fix(image-updates): surface digest warnings on editor and mobile paths Editor Update discarded recheckWarning, digest hints were hover-only, and service-scoped rechecks blamed the daemon when only sibling services remained stale.
214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
/**
|
|
* Manual POST /api/stacks/:name/update must start the health gate before
|
|
* registry recheck, never blind-clear update status, and keep Compose success
|
|
* as HTTP 200 even when recheck throws.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
import { UPDATE_DIGEST_UNCHANGED_WARNING } from '../services/ImageUpdateService';
|
|
|
|
const {
|
|
mockExecute,
|
|
mockRecheckStack,
|
|
mockBeginStack,
|
|
mockClearStackUpdateStatus,
|
|
mockBroadcastEvent,
|
|
mockDispatchAlert,
|
|
} = vi.hoisted(() => ({
|
|
mockExecute: vi.fn(),
|
|
mockRecheckStack: vi.fn(),
|
|
mockBeginStack: vi.fn(),
|
|
mockClearStackUpdateStatus: vi.fn(),
|
|
mockBroadcastEvent: vi.fn(),
|
|
mockDispatchAlert: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/StackUpdateOrchestrator', () => ({
|
|
StackUpdateOrchestrator: {
|
|
getInstance: () => ({ execute: mockExecute }),
|
|
},
|
|
shortImageId: (id: string) => id.slice(0, 12),
|
|
}));
|
|
|
|
vi.mock('../services/ImageUpdateService', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/ImageUpdateService')>(
|
|
'../services/ImageUpdateService',
|
|
);
|
|
return {
|
|
...actual,
|
|
ImageUpdateService: {
|
|
isChecksEnabled: () => true,
|
|
getInstance: () => ({ recheckStack: mockRecheckStack }),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/HealthGateService', () => ({
|
|
HealthGateService: {
|
|
getInstance: () => ({
|
|
beginStack: mockBeginStack,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getBaseDir: () => '/tmp/compose',
|
|
hasComposeFile: vi.fn().mockResolvedValue(true),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../helpers/policyGate', async () => {
|
|
const actual = await vi.importActual<typeof import('../helpers/policyGate')>(
|
|
'../helpers/policyGate',
|
|
);
|
|
return {
|
|
...actual,
|
|
runPolicyGate: vi.fn().mockResolvedValue(true),
|
|
triggerPostDeployScan: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
let clearSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
let broadcastSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
const callOrder: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
authCookie = await loginAsTestAdmin(app);
|
|
|
|
const { DatabaseService } = await import('../services/DatabaseService');
|
|
const { NotificationService } = await import('../services/NotificationService');
|
|
clearSpy = vi.spyOn(DatabaseService.getInstance(), 'clearStackUpdateStatus').mockImplementation((...args) => {
|
|
callOrder.push('clearStackUpdateStatus');
|
|
return mockClearStackUpdateStatus(...args);
|
|
});
|
|
broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation((...args) => {
|
|
callOrder.push('broadcastEvent');
|
|
return mockBroadcastEvent(...args);
|
|
});
|
|
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockImplementation((...args) => {
|
|
callOrder.push(`dispatchAlert:${String(args[2] ?? args[0])}`);
|
|
return mockDispatchAlert(...args) ?? Promise.resolve({ persisted: true });
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
clearSpy?.mockRestore();
|
|
broadcastSpy?.mockRestore();
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
callOrder.length = 0;
|
|
mockExecute.mockReset();
|
|
mockRecheckStack.mockReset();
|
|
mockBeginStack.mockReset();
|
|
mockClearStackUpdateStatus.mockReset();
|
|
mockBroadcastEvent.mockReset();
|
|
mockDispatchAlert.mockReset().mockResolvedValue({ persisted: true });
|
|
|
|
mockExecute.mockImplementation(async () => {
|
|
callOrder.push('execute');
|
|
return { kind: 'stack_compose_done', recoveryId: null };
|
|
});
|
|
mockBeginStack.mockImplementation(() => {
|
|
callOrder.push('beginStack');
|
|
return 'gate-1';
|
|
});
|
|
mockRecheckStack.mockImplementation(async () => {
|
|
callOrder.push('recheckStack');
|
|
return { outcome: 'cleared', warning: null };
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stacks/:name/update post-compose verification', () => {
|
|
it('starts the health gate before recheck, skips clear, and broadcasts after recheck', async () => {
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/update')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ status: 'Update completed', healthGateId: 'gate-1' });
|
|
expect(res.body.recheckWarning).toBeUndefined();
|
|
expect(callOrder.indexOf('execute')).toBeLessThan(callOrder.indexOf('beginStack'));
|
|
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
|
|
expect(callOrder.indexOf('recheckStack')).toBeLessThan(callOrder.indexOf('broadcastEvent'));
|
|
expect(callOrder).not.toContain('clearStackUpdateStatus');
|
|
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns recheckWarning when the update condition remains', async () => {
|
|
mockRecheckStack.mockImplementation(async () => {
|
|
callOrder.push('recheckStack');
|
|
return {
|
|
outcome: 'still_present',
|
|
warning: 'The update command completed, but Sencho still detects an available image update.',
|
|
};
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/update')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.recheckWarning).toBe(
|
|
'The update command completed, but Sencho still detects an available image update.',
|
|
);
|
|
});
|
|
|
|
it('surfaces the digest-unchanged warning when the image digest did not move after update', async () => {
|
|
mockRecheckStack.mockImplementation(async () => {
|
|
callOrder.push('recheckStack');
|
|
return {
|
|
outcome: 'still_present',
|
|
warning: UPDATE_DIGEST_UNCHANGED_WARNING,
|
|
};
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/update')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.recheckWarning).toBe(UPDATE_DIGEST_UNCHANGED_WARNING);
|
|
});
|
|
|
|
it('keeps HTTP 200 and success notification when recheck throws after Compose', async () => {
|
|
mockRecheckStack.mockImplementation(async () => {
|
|
callOrder.push('recheckStack');
|
|
throw new Error('registry blew up');
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/update')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.healthGateId).toBe('gate-1');
|
|
expect(res.body.recheckWarning).toMatch(/could not fully verify/i);
|
|
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
|
|
expect(callOrder).toContain('broadcastEvent');
|
|
// Success path still notifies; failure notification must not fire.
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'info',
|
|
'image_update_applied',
|
|
expect.any(String),
|
|
expect.objectContaining({ stackName: 'web' }),
|
|
);
|
|
expect(mockDispatchAlert.mock.calls.some((c) => c[0] === 'error' && c[1] === 'deploy_failure')).toBe(false);
|
|
});
|
|
});
|