import fs from 'fs/promises'; import path from 'path'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { cleanupTestDb, setupTestDb } from './helpers/setupTestDb'; let tmpDir: string; let ImageOperationService: typeof import('../services/ImageOperationService').ImageOperationService; let SelfUpdateService: typeof import('../services/SelfUpdateService').default; let HardenedEntitlementService: typeof import('../services/HardenedEntitlementService').HardenedEntitlementService; let RegistryService: typeof import('../services/RegistryService').RegistryService; beforeAll(async () => { tmpDir = await setupTestDb(); ({ ImageOperationService } = await import('../services/ImageOperationService')); SelfUpdateService = (await import('../services/SelfUpdateService')).default; ({ HardenedEntitlementService } = await import('../services/HardenedEntitlementService')); ({ RegistryService } = await import('../services/RegistryService')); }); afterEach(async () => { vi.restoreAllMocks(); const selfUpdate = SelfUpdateService.getInstance() as unknown as { pendingHelperExitError: string | undefined; helperExitListeners: Array<(error: string | null) => void>; }; selfUpdate.pendingHelperExitError = undefined; selfUpdate.helperExitListeners = []; await fs.rm(path.join(tmpDir, 'image-operation-current.json'), { force: true }); await fs.rm(path.join(tmpDir, 'image-operations'), { recursive: true, force: true }); }); afterAll(() => cleanupTestDb(tmpDir)); describe('ImageOperationService', () => { it('rejects a second update while the first operation is claimed', async () => { let releaseUpdate: (() => void) | undefined; let markTriggered: (() => void) | undefined; const triggered = new Promise(resolve => { markTriggered = resolve; }); vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => { markTriggered!(); await new Promise(resolve => { releaseUpdate = resolve; }); }); const service = ImageOperationService.getInstance(); const first = service.runCommunityUpdate(); await triggered; const second = await service.runCommunityUpdate(); expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }); releaseUpdate!(); await expect(first).resolves.toEqual({ ok: true }); }); it('claims synchronously before checking existing operations', async () => { let releaseLookup: (() => void) | undefined; let signalLookupStarted: (() => void) | undefined; const lookupStarted = new Promise(resolve => { signalLookupStarted = resolve; }); let lookups = 0; const service = ImageOperationService.getInstance(); vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(service, 'getCurrentOperation').mockImplementation(async () => { lookups += 1; if (lookups === 1) { signalLookupStarted!(); await new Promise(resolve => { releaseLookup = resolve; }); } return null; }); const first = service.claimCommunityUpdate(); await lookupStarted; const second = await service.claimCommunityUpdate(); releaseLookup!(); await expect(first).resolves.toEqual({ ok: true }); expect(second).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }); }); it('persists a terminal failure when the update helper reports an error', async () => { vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed'); const result = await ImageOperationService.getInstance().runCommunityUpdate(); const current = await ImageOperationService.getInstance().getCurrentOperation(); expect(result).toEqual({ ok: false, failureCode: 'update_failed' }); expect(current?.state).toBe('failed'); expect(current?.failureCode).toBe('update_failed'); }); it('fails a hardened update when the helper reports an error and binds its marker to the operation', async () => { const entitlement = { success: true as const, entitlement: { hardened_build_access: true, channel: 'hardened' as const, allowed_image_ref: 'ghcr.io/studio-saelix/sencho-hardened:latest', pin_recommendation: 'ghcr.io/studio-saelix/sencho-hardened:latest', checked_at: '2026-07-13T00:00:00.000Z', registry_requirement: { registry_host: 'ghcr.io', package_scope: 'studio-saelix/sencho-hardened', credential_instructions: 'Use a pull token.', supports_pull_token: true, }, }, }; const resolved = { filePath: '/compose.yml', imageRef: 'ghcr.io/studio-saelix/sencho-hardened:latest', pinKind: 'semver' as const, fileContent: 'services: {}', }; let markerFile = ''; let markerContent = ''; vi.spyOn(HardenedEntitlementService.getInstance(), 'getEntitlement').mockResolvedValue(entitlement); vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(resolved); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(RegistryService.getInstance(), 'resolveDockerConfigForHost').mockResolvedValue({ config: { auths: { 'ghcr.io': { auth: 'credential' } } }, warnings: [], }); vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async options => { markerFile = options?.successMarkerFile ?? ''; markerContent = options?.successMarkerContent ?? ''; }); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed'); const service = ImageOperationService.getInstance(); const fingerprint = service.computePreflightFingerprint( resolved.filePath, resolved.imageRef, resolved.pinKind, entitlement.entitlement.allowed_image_ref, ); const result = await service.switchToHardened(fingerprint); const current = await service.getCurrentOperation(); expect(result).toEqual({ ok: false, code: 'update_failed' }); expect(current?.state).toBe('failed'); expect(markerFile).toBe(path.join(tmpDir, `image-op-success-${current?.operationId}.json`)); expect(markerContent).toBe(JSON.stringify({ ok: true, operationId: current?.operationId })); }); it('fails a recreating community op when the helper exits after handoff', async () => { let helperExit: ((error: string | null) => void) | undefined; const callOrder: string[] = []; vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => { callOrder.push('watch'); helperExit = listener; }); vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockImplementation(async () => { callOrder.push('trigger'); }); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); const service = ImageOperationService.getInstance(); const result = await service.runCommunityUpdate(); const mid = await service.getCurrentOperation(); expect(result).toEqual({ ok: true }); expect(mid?.state).toBe('recreating'); expect(callOrder).toEqual(['watch', 'trigger']); expect(helperExit).toBeTypeOf('function'); helperExit!('Helper container exited without restarting Sencho'); await vi.waitFor(async () => { const current = await service.getCurrentOperation(); expect(current?.state).toBe('failed'); expect(current?.failureCode).toBe('update_failed'); }); }); it('does not let recreating persist overwrite a concurrent helper-exit failure', async () => { let helperExit: ((error: string | null) => void) | undefined; vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation((listener) => { helperExit = listener; }); vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); const service = ImageOperationService.getInstance(); const proto = Object.getPrototypeOf(service) as { persist: (operation: unknown) => Promise; }; const realPersist = proto.persist.bind(service); vi.spyOn(service as unknown as { persist: (operation: { state: string }) => Promise }, 'persist') .mockImplementation(async (operation) => { if (operation.state === 'recreating' && helperExit) { const exit = helperExit; helperExit = undefined; // Fail must land on disk before the recreating write runs. That is the // overwrite order without CAS: helper-exit failed, then late recreating. exit('Helper container exited without restarting Sencho'); await vi.waitFor(async () => { const current = await service.getCurrentOperation(); expect(current?.state).toBe('failed'); expect(current?.failureCode).toBe('update_failed'); }); await realPersist(operation); return; } return realPersist(operation); }); await service.runCommunityUpdate(); const current = await service.getCurrentOperation(); expect(current?.state).toBe('failed'); expect(current?.failureCode).toBe('update_failed'); }); it('does not let acknowledging a stale failure replace an active current operation', async () => { vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(SelfUpdateService.getInstance(), 'triggerUpdate').mockResolvedValue(undefined); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue('pull failed'); const service = ImageOperationService.getInstance(); await service.runCommunityUpdate(); const failedA = await service.getCurrentOperation(); expect(failedA?.state).toBe('failed'); const operationAId = failedA!.operationId; const claimB = await service.claimCommunityUpdate(); expect(claimB).toEqual({ ok: true }); const currentB = await service.getCurrentOperation(); expect(currentB?.state).toBe('pending_pull'); expect(currentB?.operationId).not.toBe(operationAId); expect(await service.acknowledge(operationAId)).toBe(true); const stillB = await service.getCurrentOperation(); expect(stillB?.operationId).toBe(currentB!.operationId); expect(stillB?.state).toBe('pending_pull'); const ackedA = await service.getOperation(operationAId); expect(ackedA?.acknowledgedAt).toBeTruthy(); const claimC = await service.claimCommunityUpdate(); expect(claimC).toEqual({ ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }); }); it('replays a pending helper exit to a late onceHelperExit listener', async () => { const svc = SelfUpdateService.getInstance(); const internal = svc as unknown as { pendingHelperExitError: string | undefined }; internal.pendingHelperExitError = 'Helper container exited without restarting Sencho'; const seen = new Promise((resolve) => { svc.onceHelperExit(resolve); }); await expect(seen).resolves.toBe('Helper container exited without restarting Sencho'); expect(internal.pendingHelperExitError).toBeUndefined(); }); it('changes the fingerprint when a preflight value changes', () => { const service = ImageOperationService.getInstance(); const baseline = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:aaa'); const changed = service.computePreflightFingerprint('/compose.yml', 'saelix/sencho:1.0.0', 'semver', 'ghcr.io/studio-saelix/sencho-hardened@sha256:bbb'); expect(changed).not.toBe(baseline); }); it('executeClaimedComposeReapply transitions to recreating and watches helper exit before trigger', async () => { const callOrder: string[] = []; vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation(() => { callOrder.push('watch'); }); vi.spyOn(SelfUpdateService.getInstance(), 'triggerComposeReapply').mockImplementation(async () => { callOrder.push('trigger'); }); vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); const service = ImageOperationService.getInstance(); const claim = await service.claimComposeReapply(); expect(claim).toEqual({ ok: true }); const result = await service.executeClaimedComposeReapply(); const current = await service.getCurrentOperation(); expect(result).toEqual({ ok: true }); expect(current?.kind).toBe('compose_reapply'); expect(current?.state).toBe('recreating'); expect(callOrder).toEqual(['watch', 'trigger']); }); it('reconcileOnStartup resolves compose_reapply via marker-only success without pin match', async () => { const service = ImageOperationService.getInstance(); vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); const claim = await service.claimComposeReapply(); expect(claim).toEqual({ ok: true }); const current = await service.getCurrentOperation(); expect(current?.kind).toBe('compose_reapply'); expect(current?.targetImageRef).toBeNull(); const markerPath = path.join(tmpDir, `image-op-success-${current!.operationId}.json`); await fs.writeFile(markerPath, JSON.stringify({ ok: true, operationId: current!.operationId }), 'utf8'); await service.reconcileOnStartup(); const resolved = await service.getCurrentOperation(); expect(resolved?.state).toBe('succeeded'); }); });