// tests/main/services/update-service-redteam-r2-11.test.ts // 자동 업데이트 회귀 테스트 (라운드 2, 가짜 포트 주입): // 1) 킬 스위치·rollout 중단이 받는 중/받아 둔 설치 파일의 '종료 시 설치'까지 막는다 // 2) 열린 동의 대화상자의 동의가 electron-updater 가 나중에 본(게이팅된) 버전에 묶이지 않는다 // 3) 강제·필수 업데이트는 재시작 단계에서 미룰 수 없다 // // 가짜 업데이터는 electron-updater 의 관련 동작만 흉내 낸다: // - update-available 직전에 대상 버전을 교체하고, downloadUpdate() 는 그 대상을 받는다 // - 다운로드가 끝나면 autoInstallOnAppQuit 가 켜져 있을 때만 종료 핸들러를 건다 // - 종료 시 autoInstallOnAppQuit 가 여전히 켜져 있을 때만 설치한다 import { describe, it, expect, beforeEach, vi } from 'vitest' import { DEFAULT_UPDATE_POLICY, rolloutBucket, type UpdatePolicy } from '../../../src/main/update-policy' import type { ConsentAnswer, PolicySource, RestartAnswer, UpdatePrompter, UpdaterEventHandlers, UpdaterPort, } from '../../../src/main/services/UpdateService' vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })) const store = vi.hoisted(() => ({ values: {} as Record })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn((key: string) => store.values[key]), configSet: vi.fn((key: string, value: unknown) => { store.values[key] = value }), configGetAll: vi.fn(() => store.values), })) type ServiceModule = typeof import('../../../src/main/services/UpdateService') let UpdateService: ServiceModule['UpdateService'] function policy(overrides: Partial = {}): UpdatePolicy { return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides } } async function flush(): Promise { for (let i = 0; i < 10; i++) await new Promise((resolve) => setImmediate(resolve)) } interface Deferred { promise: Promise resolve(value: T): void } function deferred(): Deferred { let resolve!: (value: T) => void const promise = new Promise((r) => { resolve = r }) return { promise, resolve } } function harness(opts: { policy?: UpdatePolicy currentVersion?: string consent?: () => Promise restart?: () => Promise }) { let currentPolicy = opts.policy ?? policy() let handlers: UpdaterEventHandlers | null = null const fake = { /** electron-updater 의 updateInfoAndProvider 에 해당 */ target: null as string | null, downloading: null as string | null, downloaded: null as string | null, autoInstallOnAppQuit: true, quitHandlerAdded: false, downloads: [] as string[], checks: 0, installs: [] as string[], } const updater: UpdaterPort = { checkForUpdates: vi.fn(async () => { fake.checks++ }), downloadUpdate: vi.fn(async () => { if (fake.target) { fake.downloads.push(fake.target) fake.downloading = fake.target } }), quitAndInstall: vi.fn(() => { if (fake.downloaded) fake.installs.push(fake.downloaded) }), setChannel: vi.fn(), setDifferentialDisabled: vi.fn(), setAutoInstallOnQuit: vi.fn((enabled: boolean) => { fake.autoInstallOnAppQuit = enabled }), } const policySource: PolicySource = { load: vi.fn(async () => currentPolicy) } const prompter: UpdatePrompter = { askConsent: vi.fn(opts.consent ?? (async () => 'download' as const)), askRestart: vi.fn(opts.restart ?? (async () => 'later' as const)), } const service = new UpdateService({ createUpdater: (h) => { handlers = h return updater }, policySource, prompter, currentVersion: () => opts.currentVersion ?? '1.9.0', now: () => 1_000_000, }) service.initialize() service.dispose() return { service, updater, prompter, fake, setPolicy(next: UpdatePolicy) { currentPolicy = next }, /** electron-updater 가 새 버전을 발견: 대상 교체 → update-available */ offer(version: string) { fake.target = version ;(handlers as UpdaterEventHandlers).onAvailable({ version }) }, /** 다운로드 완료: update-downloaded → (동기로) 종료 핸들러 등록 */ finishDownload() { const version = fake.downloading as string fake.downloading = null fake.downloaded = version ;(handlers as UpdaterEventHandlers).onDownloaded(version) if (fake.autoInstallOnAppQuit) fake.quitHandlerAdded = true }, /** 앱 종료: electron-updater 종료 핸들러 흉내 */ quitApp() { if (fake.quitHandlerAdded && fake.autoInstallOnAppQuit && fake.downloaded) { fake.installs.push(fake.downloaded) } }, } } beforeEach(async () => { vi.resetModules() store.values = { updateDeviceId: 'device-1' } UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService }) describe('1) 킬 스위치·rollout 중단은 받아 둔 설치 파일에도 적용된다', () => { it("'종료 시 설치'를 고른 뒤 킬 스위치가 켜지면 종료해도 설치하지 않는다", async () => { const h = harness({}) await h.service.checkForUpdates() h.offer('1.9.1') await flush() expect(h.fake.downloads).toEqual(['1.9.1']) h.finishDownload() await flush() expect(h.prompter.askRestart).toHaveBeenCalledTimes(1) h.setPolicy(policy({ killSwitch: true })) await h.service.checkForUpdates() expect(h.fake.autoInstallOnAppQuit).toBe(false) h.quitApp() expect(h.fake.installs).toEqual([]) }) it('stagingPercentage=0 으로 rollout 을 멈춰도 받아 둔 설치 파일은 설치하지 않는다', async () => { const h = harness({}) await h.service.checkForUpdates() h.offer('1.9.1') await flush() h.finishDownload() await flush() h.setPolicy(policy({ stagingPercentage: 0 })) await h.service.checkForUpdates() h.quitApp() expect(h.fake.installs).toEqual([]) }) it('받는 중에 킬 스위치가 켜지면 다운로드가 끝나도 재시작을 묻지 않고 종료 핸들러도 걸리지 않는다', async () => { const h = harness({}) await h.service.checkForUpdates() h.offer('1.9.1') await flush() expect(h.fake.downloading).toBe('1.9.1') h.setPolicy(policy({ killSwitch: true })) await h.service.checkForUpdates() expect(h.fake.autoInstallOnAppQuit).toBe(false) h.finishDownload() await flush() expect(h.prompter.askRestart).not.toHaveBeenCalled() h.quitApp() expect(h.fake.installs).toEqual([]) }) it('킬 스위치가 풀리면 종료 시 설치를 되살린다', async () => { const h = harness({}) await h.service.checkForUpdates() h.offer('1.9.1') await flush() h.finishDownload() await flush() h.setPolicy(policy({ killSwitch: true })) await h.service.checkForUpdates() expect(h.fake.autoInstallOnAppQuit).toBe(false) h.setPolicy(policy()) await h.service.checkForUpdates() expect(h.fake.autoInstallOnAppQuit).toBe(true) h.quitApp() expect(h.fake.installs).toEqual(['1.9.1']) }) it("재시작 대화상자가 떠 있는 동안 킬 스위치가 켜지면 '지금 재시작'이어도 설치하지 않는다", async () => { const answer = deferred() const h = harness({ restart: () => answer.promise }) await h.service.checkForUpdates() h.offer('1.9.1') await flush() h.finishDownload() await flush() h.setPolicy(policy({ killSwitch: true })) answer.resolve('restart') await flush() expect(h.updater.quitAndInstall).not.toHaveBeenCalled() expect(h.fake.autoInstallOnAppQuit).toBe(false) }) }) describe('2) 동의는 동의한 버전에만 묶인다', () => { it('동의 대화상자가 열려 있는 동안에는 업데이터 체크를 건너뛴다(정책은 갱신)', async () => { const consent = deferred() const h = harness({ consent: () => consent.promise }) await h.service.checkForUpdates() h.offer('1.9.1') await flush() expect(h.prompter.askConsent).toHaveBeenCalledTimes(1) const checksBefore = vi.mocked(h.updater.checkForUpdates).mock.calls.length await h.service.checkForUpdates() expect(vi.mocked(h.updater.checkForUpdates).mock.calls.length).toBe(checksBefore) consent.resolve('download') await flush() expect(h.fake.downloads).toEqual(['1.9.1']) }) it('대화상자가 열린 사이 rollout 밖의 1.9.2 가 대상이 되면, 1.9.1 동의로 1.9.2 를 받지 않는다', async () => { // 1.9.1 은 rollout 안, 1.9.2 는 rollout 밖인 기기를 고른다 let deviceId = '' for (let i = 0; i < 1000 && !deviceId; i++) { const candidate = `device-${i}` if (rolloutBucket(candidate, '1.9.1') < 50 && rolloutBucket(candidate, '1.9.2') >= 50) deviceId = candidate } expect(deviceId).not.toBe('') store.values.updateDeviceId = deviceId const consent = deferred() const h = harness({ policy: policy({ stagingPercentage: 50 }), consent: () => consent.promise }) await h.service.checkForUpdates() h.offer('1.9.1') await flush() // 누군가(수동 체크 등) 업데이터를 다시 돌려 1.9.2 가 대상이 됐다 — 게이팅은 1.9.2 를 무시 h.offer('1.9.2') await flush() expect(h.prompter.askConsent).toHaveBeenCalledTimes(1) consent.resolve('download') await flush() expect(h.updater.downloadUpdate).not.toHaveBeenCalled() expect(h.fake.downloads).toEqual([]) }) it('대화상자가 열린 사이 킬 스위치가 켜지면 동의해도 받지 않는다', async () => { const consent = deferred() const h = harness({ consent: () => consent.promise }) await h.service.checkForUpdates() h.offer('1.9.1') await flush() h.setPolicy(policy({ killSwitch: true })) await h.service.checkForUpdates() consent.resolve('download') await flush() expect(h.fake.downloads).toEqual([]) }) }) describe('3) 강제·필수 업데이트는 재시작 단계에서 미룰 수 없다', () => { it('forceInstallBelow 미만이면 동의 없이 받고, 재시작 대화상자에서 미뤄도 설치한다', async () => { const h = harness({ policy: policy({ forceInstallBelow: '1.9.1' }), restart: async () => 'later' }) await h.service.checkForUpdates() h.offer('1.9.1') await flush() expect(h.prompter.askConsent).not.toHaveBeenCalled() expect(h.fake.downloads).toEqual(['1.9.1']) h.finishDownload() await flush() expect(h.prompter.askRestart).toHaveBeenCalledWith('1.9.1', { mandatory: true }) expect(h.updater.quitAndInstall).toHaveBeenCalledWith(false, true) expect(h.fake.installs).toEqual(['1.9.1']) }) it('최소 지원 버전 미만(필수)도 재시작을 미룰 수 없다', async () => { const h = harness({ policy: policy({ minimumSupportedVersion: '1.9.1' }), restart: async () => 'later' }) await h.service.checkForUpdates() h.offer('1.9.1') await flush() h.finishDownload() await flush() expect(h.prompter.askRestart).toHaveBeenCalledWith('1.9.1', { mandatory: true }) expect(h.updater.quitAndInstall).toHaveBeenCalledTimes(1) }) it("일반 업데이트는 '종료 시 설치'를 고르면 지금 설치하지 않고, '지금 재시작'이면 설치한다", async () => { const later = harness({ restart: async () => 'later' }) await later.service.checkForUpdates() later.offer('1.9.1') await flush() later.finishDownload() await flush() expect(later.prompter.askRestart).toHaveBeenCalledWith('1.9.1', { mandatory: false }) expect(later.updater.quitAndInstall).not.toHaveBeenCalled() expect(later.fake.autoInstallOnAppQuit).toBe(true) const now = harness({ restart: async () => 'restart' }) await now.service.checkForUpdates() now.offer('1.9.1') await flush() now.finishDownload() await flush() expect(now.updater.quitAndInstall).toHaveBeenCalledWith(false, true) }) })