import assert from 'node:assert/strict' import { createHash, generateKeyPairSync } from 'node:crypto' import { createServer, type ServerResponse } from 'node:http' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { signPayload as releaseSignPayload } from './updates/lib.mjs' import { FileUpdatePersistence } from '../src/main/updates/storage' import { compareVersions, parseVersion } from '../src/main/updates/semver' import { normalizeManifestUrl, verifySignedManifest } from '../src/main/updates/manifest' import { isTrustedUpdateRendererUrl } from '../src/main/updates/trust' import { createNsisInstaller, NSIS_UNINSTALL_KEY } from '../src/main/updates/installer' import { sanitizeLogText } from '../src/main/updates/logger' import { UpdateManager } from '../src/main/updates/manager' import { UpdateError, type CachedInstaller, type FetchLike, type InstallEnvironment, type InstallerAdapter, type SignedUpdateManifest, type UpdateManifestPayload } from '../src/main/updates/types' const tests: Array<[string, () => Promise | void]> = [] const test = (name: string, run: () => Promise | void): void => { tests.push([name, run]) } const sleep = (milliseconds: number): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)) async function waitFor(predicate: () => boolean | Promise, timeout = 5_000): Promise { const deadline = Date.now() + timeout while (!await predicate()) { if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition') await sleep(10) } } function sha256(bytes: Buffer): string { return createHash('sha256').update(bytes).digest('hex') } function makeBytes(size: number, fill: number): Buffer { const bytes = Buffer.allocUnsafe(size) for (let index = 0; index < size; index += 1) bytes[index] = (fill + index) & 0xff return bytes } function expectUpdateError(run: () => unknown, code: string): void { assert.throws(run, (error: unknown) => error instanceof UpdateError && error.code === code) } const { privateKey, publicKey } = generateKeyPairSync('ed25519') const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() const publicPem = publicKey.export({ type: 'spki', format: 'pem' }).toString() function manifestFor( origin: string, version: string, bytes: Buffer, notes = { zh: `更新 ${version}`, en: `Release ${version}` }, filename = `YCode-${version}-Setup.exe` ): SignedUpdateManifest { const payload: UpdateManifestPayload = { schemaVersion: 1, version, publishedAt: '2026-10-20T09:00:00.000Z', notes, platforms: { 'windows-x64': { url: `${origin}/releases/versions/${version}/${filename}`, size: bytes.byteLength, sha256: sha256(bytes) } } } return { payload, signature: releaseSignPayload(payload, privatePem) } } interface HttpFixture { latest: SignedUpdateManifest | null files: Map requests: string[] downloadRanges: Array redirect: 'none' | 'same-origin' | 'cross-origin' crossOrigin: string oversizeManifest: boolean interruptNextDownload: boolean chunkSize: number chunkDelayMs: number downloadStarted: number origin: string close: () => Promise } async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise { for (let offset = 0; offset < body.byteLength; offset += fixture.chunkSize) { if (response.destroyed) return const chunk = body.subarray(offset, Math.min(body.byteLength, offset + fixture.chunkSize)) if (!response.write(chunk)) await new Promise(resolve => response.once('drain', resolve)) if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs) } if (!response.destroyed) response.end() } async function createHttpFixture(): Promise { let server: ReturnType const fixture: HttpFixture = { latest: null, files: new Map(), requests: [], downloadRanges: [], redirect: 'none', crossOrigin: '', oversizeManifest: false, interruptNextDownload: false, chunkSize: 64 * 1024, chunkDelayMs: 0, downloadStarted: 0, origin: '', close: async () => { server.closeAllConnections() await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) } } server = createServer((request, response) => { response.on('error', () => undefined) const url = new URL(request.url ?? '/', fixture.origin) fixture.requests.push(url.pathname) if (url.pathname === '/releases/latest.json') { if (fixture.oversizeManifest) { const length = 1024 * 1024 + 1 response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(length) }) response.end(Buffer.alloc(length, 0x20)) return } if (fixture.redirect === 'same-origin' && !url.searchParams.has('redirected')) { response.writeHead(302, { location: '/releases/latest.json?redirected=1' }) response.end() return } if (fixture.redirect === 'cross-origin') { response.writeHead(302, { location: `${fixture.crossOrigin}/releases/latest.json` }) response.end() return } const body = Buffer.from(JSON.stringify(fixture.latest)) response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(body.byteLength) }) response.end(body) return } const file = fixture.files.get(url.pathname) if (!file) { response.writeHead(404) response.end() return } const rangeMatch = /^bytes=(\d+)-$/.exec(request.headers.range ?? '') const range = rangeMatch ? Number(rangeMatch[1]) : null fixture.downloadRanges.push(rangeMatch?.[0] ?? null) fixture.downloadStarted += 1 if (range !== null && range >= file.byteLength) { response.writeHead(416, { 'content-range': `bytes */${file.byteLength}` }) response.end() return } const start = range ?? 0 if (fixture.interruptNextDownload && start === 0) { fixture.interruptNextDownload = false const partial = file.subarray(0, Math.max(1, Math.floor(file.byteLength / 2))) response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(file.byteLength) }) response.write(partial) setTimeout(() => response.destroy(new Error('simulated interruption')), 20) return } response.writeHead(start > 0 ? 206 : 200, { 'content-type': 'application/octet-stream', 'content-length': String(file.byteLength - start), ...(start > 0 ? { 'content-range': `bytes ${start}-${file.byteLength - 1}/${file.byteLength}` } : {}) }) void sendBody(response, file.subarray(start), fixture).catch(() => undefined) }) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(0, '127.0.0.1', () => resolve()) }) const address = server.address() if (!address || typeof address === 'string') throw new Error('Fake HTTP server did not bind') fixture.origin = `http://127.0.0.1:${address.port}` return fixture } class FakeInstaller implements InstallerAdapter { validateCount = 0 verifyCount = 0 launchCount = 0 launchError: Error | null = null onLaunch: (() => Promise) | null = null async validateEnvironment(_requiredBytes?: number): Promise { this.validateCount += 1 return { installationDirectory: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode', executablePath: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode\\YCode.exe', registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}` } } async verifyInstaller(_installer: CachedInstaller): Promise { this.verifyCount += 1 } async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise { this.launchCount += 1 if (this.onLaunch) await this.onLaunch() if (this.launchError) throw this.launchError } } interface ManagerFixture { manager: UpdateManager installer: FakeInstaller persistence: FileUpdatePersistence cacheDirectory: string controls: { beforeResult: boolean beforeGate: Promise | null beforeCalls: number failedCalls: number quitCalls: number quitError: Error | null } } let managerSequence = 0 function createManagerFixture( root: string, origin: string, options: { autoDownload?: boolean; currentVersion?: string; identity?: string } = {} ): ManagerFixture { managerSequence += 1 const directory = join(root, `manager-${options.identity ?? managerSequence}`) const installer = new FakeInstaller() const persistence = new FileUpdatePersistence(join(directory, 'state.json')) const controls: ManagerFixture['controls'] = { beforeResult: true, beforeGate: null, beforeCalls: 0, failedCalls: 0, quitCalls: 0, quitError: null } const manager = new UpdateManager({ currentVersion: options.currentVersion ?? '0.0.1', manifestUrl: `${origin}/releases/latest.json`, cacheDirectory: join(directory, 'cache'), logFile: join(directory, 'updater.log'), persistence, publicKey: async () => publicPem, supported: true, installer, beforeInstall: async () => { controls.beforeCalls += 1 if (controls.beforeGate) return controls.beforeGate return controls.beforeResult }, quitForUpdate: async () => { controls.quitCalls += 1 if (controls.quitError) throw controls.quitError }, installFailed: async () => { controls.failedCalls += 1 }, autoDownload: options.autoDownload ?? false, checkDelayMs: 5, checkIntervalMs: 60_000, upToDateResetMs: 30, manifestTimeoutMs: 2_000, downloadTimeoutMs: 10_000 }) return { manager, installer, persistence, cacheDirectory: join(directory, 'cache'), controls } } test('strict SemVer parser and comparator', () => { assert.deepEqual(parseVersion('1.2.3-alpha.1+build.7').prerelease, ['alpha', '1']) assert.equal(compareVersions('1.2.3+build.1', '1.2.3+build.2'), 0) assert.equal(compareVersions('1.2.3-alpha.2', '1.2.3-alpha.10'), -1) assert.equal(compareVersions('1.2.3', '1.2.3-rc.1'), 1) expectUpdateError(() => parseVersion('01.2.3'), 'invalid-version') expectUpdateError(() => parseVersion('1.2.3-01'), 'invalid-version') }) test('manifest signature, exact metadata and URL policy', async () => { const origin = 'http://127.0.0.1:65530' const bytes = makeBytes(1024, 5) const manifest = manifestFor(origin, '1.0.1', bytes) const verified = verifySignedManifest(Buffer.from(JSON.stringify(manifest)), { manifestUrl: `${origin}/releases/latest.json`, publicKeyPem: publicPem }) assert.equal(verified.payload.version, '1.0.1') const tampered = structuredClone(manifest) tampered.payload.platforms['windows-x64'].sha256 = '0'.repeat(64) expectUpdateError( () => verifySignedManifest(Buffer.from(JSON.stringify(tampered)), { manifestUrl: `${origin}/releases/latest.json`, publicKeyPem: publicPem }), 'invalid-signature' ) const invalidVersionPayload = structuredClone(manifest.payload) as unknown as Record invalidVersionPayload.version = '01.0.0' const invalidVersionDocument = { payload: invalidVersionPayload, signature: releaseSignPayload(invalidVersionPayload, privatePem) } expectUpdateError( () => verifySignedManifest(Buffer.from(JSON.stringify(invalidVersionDocument)), { manifestUrl: `${origin}/releases/latest.json`, publicKeyPem: publicPem }), 'invalid-version' ) const outsidePath = structuredClone(manifest.payload) outsidePath.platforms['windows-x64'].url = `${origin}/releases/other/YCode.exe` const outsidePathDocument = { payload: outsidePath, signature: releaseSignPayload(outsidePath, privatePem) } expectUpdateError( () => verifySignedManifest(Buffer.from(JSON.stringify(outsidePathDocument)), { manifestUrl: `${origin}/releases/latest.json`, publicKeyPem: publicPem }), 'invalid-manifest' ) assert.equal(normalizeManifestUrl(`${origin}/releases/latest.json`), `${origin}/releases/latest.json`) }) test('trusted renderer URL gate rejects task and foreign frames', () => { const appPath = 'C:\\Program Files\\YCode\\resources\\app.asar' const mainFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\index.html').href const settingsFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\settings.html').href assert.equal(isTrustedUpdateRendererUrl(mainFile, { appPath, platform: 'win32' }), true) assert.equal(isTrustedUpdateRendererUrl(settingsFile, { appPath, platform: 'win32' }), true) assert.equal(isTrustedUpdateRendererUrl(`${mainFile}?window=task`, { appPath, platform: 'win32' }), false) assert.equal(isTrustedUpdateRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts', { appPath, platform: 'win32' }), false) assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/index.html?window=task', { appPath, rendererUrl: 'http://localhost:5173' }), false) assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/settings.html', { appPath, rendererUrl: 'http://localhost:5173' }), true) assert.equal(isTrustedUpdateRendererUrl('http://127.0.0.1:5173/settings.html', { appPath, rendererUrl: 'http://localhost:5173' }), false) }) test('installer adapter uses spawn, current key and NSIS arguments', async () => { assert.equal(NSIS_UNINSTALL_KEY.includes('8a4ee941-d893-569b-8bcb-2a55d270c04b'), true) const root = 'C:\\Users\\Test\\AppData\\Local\\YCode Custom\\Install Dir' const executable = `${root}\\YCode.exe` const cacheDirectory = join(await mkdtemp(join(tmpdir(), 'ycode-update-adapter-')), 'cache') const calls: Array<{ command: string; args: readonly string[]; detached: boolean; stdio: string }> = [] const child = { once(event: 'spawn' | 'error', listener: (() => void) | ((error: Error) => void)) { if (event === 'spawn') queueMicrotask(() => (listener as () => void)()) return child }, unref() {} } const baseOptions = { platform: 'win32', isPackaged: true, execPath: executable, appPath: `${root}\\resources\\app.asar`, cacheDirectory, processId: 4321, resolveRealPath: async value => value, readStat: async () => ({ isFile: () => true }), queryRegistryValue: async name => { if (name === 'UninstallString') return `"${root}\\Uninstall YCode.exe" /currentuser` if (name === 'DisplayIcon') return `${executable},0` return null }, verifyFile: async () => undefined, spawn: (command, args, options) => { calls.push({ command, args, detached: options.detached, stdio: options.stdio }) return child } } as const const adapter = createNsisInstaller({ ...baseOptions, readDiskStats: async () => ({ bsize: 4096, bavail: 1_000_000 }) }) const environment = await adapter.validateEnvironment(1024) assert.equal(environment.installationDirectory, root) const cached: CachedInstaller = { version: '1.0.2', path: join(cacheDirectory, 'YCode-1.0.2-Setup.exe'), partPath: join(cacheDirectory, 'YCode-1.0.2-Setup.exe.part'), size: 10, sha256: 'a'.repeat(64) } await adapter.launch(cached, environment) assert.equal(calls.length, 1) assert.equal(calls[0].detached, true) assert.equal(calls[0].stdio, 'ignore') assert.deepEqual([...calls[0].args], [ '/S', '--updated', '--force-run', '/currentuser', '/YCODEWAITPID=4321', `/D=${root}` ]) assert.equal(calls[0].args.at(-1), `/D=${root}`) const lowSpace = createNsisInstaller({ ...baseOptions, readDiskStats: async () => ({ bsize: 1, bavail: 1 }) }) await assert.rejects( () => lowSpace.validateEnvironment(1024), (error: unknown) => error instanceof UpdateError && error.code === 'installer-space-check' ) const directoryMarker = createNsisInstaller({ ...baseOptions, readStat: async value => ({ isFile: () => !value.endsWith('app.asar') }) }) await assert.rejects( () => directoryMarker.validateEnvironment(0), (error: unknown) => error instanceof UpdateError && error.code === 'installer-marker' && /app\.asar/.test(error.message) ) }) test('HTTP manifest limits, same-origin redirects and cross-origin rejection', async () => { const first = await createHttpFixture() const second = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-http-')) try { const bytes = makeBytes(256 * 1024, 11) first.latest = manifestFor(first.origin, '1.0.1', bytes) first.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes) first.redirect = 'same-origin' const redirected = createManagerFixture(root, first.origin, { identity: 'redirect' }) const redirectedState = await redirected.manager.check() assert.equal(redirectedState.status, 'available') first.redirect = 'cross-origin' first.crossOrigin = second.origin second.latest = first.latest const cross = createManagerFixture(root, first.origin, { identity: 'cross' }) const crossState = await cross.manager.check() assert.equal(crossState.status, 'error') assert.match(crossState.error ?? '', /跨源/) first.redirect = 'none' first.oversizeManifest = true const oversized = createManagerFixture(root, first.origin, { identity: 'oversize' }) const oversizedState = await oversized.manager.check() assert.equal(oversizedState.status, 'error') assert.match(oversizedState.error ?? '', /大小限制/) } finally { await first.close() await second.close() await rm(root, { recursive: true, force: true }) } }) test('automatic download, dismissal and up-to-date idle reset', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-auto-')) try { const bytes = makeBytes(192 * 1024, 17) http.latest = manifestFor(http.origin, '1.0.1', bytes) http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes) const auto = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'auto' }) const checked = await auto.manager.checkAndDownload(false) assert.equal(checked.status, 'downloading') assert.equal(checked.version, '1.0.1') assert.equal(checked.lastExplicit, false) await waitFor(async () => (await auto.manager.getState()).status === 'ready') assert.equal((await auto.manager.getState()).progress, 100) assert.equal((await auto.manager.getState()).lastExplicit, false) http.oversizeManifest = true const retainedReady = await auto.manager.check(false) assert.equal(retainedReady.status, 'ready') assert.equal(retainedReady.lastExplicit, false) http.oversizeManifest = false const dismissed = await auto.manager.dismiss() assert.equal(dismissed.dismissedVersion, '1.0.1') assert.equal(dismissed.lastExplicit, true) const persisted = await auto.persistence.load() assert.equal(persisted.dismissedVersion, '1.0.1') const second = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'dismissed' }) await second.persistence.save({ schemaVersion: 1, dismissedVersion: '1.0.1' }) const before = http.requests.length const dismissedCheck = await second.manager.checkAndDownload() await sleep(80) assert.equal(dismissedCheck.dismissedVersion, '1.0.1') assert.equal(dismissedCheck.lastExplicit, true) assert.equal(http.requests.length - before, 1) http.latest = manifestFor(http.origin, '0.0.1', bytes) const current = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'current' }) const upToDate = await current.manager.check() assert.equal(upToDate.status, 'up-to-date') await sleep(60) assert.equal((await current.manager.getState()).status, 'idle') http.latest = manifestFor(http.origin, '1.0.1', bytes) const explicit = createManagerFixture(root, http.origin, { identity: 'explicit-reset' }) assert.equal((await explicit.manager.check(true)).lastExplicit, true) http.oversizeManifest = true const backgroundFailure = await explicit.manager.check(false) assert.equal(backgroundFailure.status, 'error') assert.equal(backgroundFailure.lastExplicit, false) http.oversizeManifest = false } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('download interruption resumes with valid Range and fixed cache filename', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-resume-')) try { const bytes = makeBytes(2 * 1024 * 1024, 23) http.latest = manifestFor(http.origin, '1.0.1', bytes) http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes) const fixture = createManagerFixture(root, http.origin, { identity: 'resume' }) assert.equal((await fixture.manager.check()).status, 'available') http.interruptNextDownload = true const interrupted = await fixture.manager.download() assert.equal(interrupted.status, 'error') const partInfo = await stat(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe.part')) assert.ok(partInfo.size > 0) const resumed = await fixture.manager.download() assert.equal(resumed.status, 'ready') assert.ok(http.downloadRanges.some(range => range?.startsWith('bytes='))) const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe')) assert.equal(sha256(cached), sha256(bytes)) } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('concurrent download is single-flight and release switch aborts stale bytes', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-race-')) try { const firstBytes = makeBytes(4 * 1024 * 1024, 31) const secondBytes = makeBytes(384 * 1024, 47) http.latest = manifestFor(http.origin, '1.0.1', firstBytes) http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', firstBytes) http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', secondBytes) http.chunkSize = 32 * 1024 http.chunkDelayMs = 15 const fixture = createManagerFixture(root, http.origin, { identity: 'race' }) assert.equal((await fixture.manager.check()).status, 'available') const staleDownload = fixture.manager.download() await waitFor(() => http.downloadStarted > 0) http.latest = manifestFor(http.origin, '1.0.2', secondBytes) const switched = await fixture.manager.check() assert.equal(switched.status, 'available') assert.equal(switched.version, '1.0.2') const staleResult = await staleDownload assert.notEqual(staleResult.status, 'ready') assert.equal((await fixture.manager.getState()).version, '1.0.2') http.chunkDelayMs = 0 const rangeCount = http.downloadRanges.length const [one, two] = await Promise.all([fixture.manager.download(), fixture.manager.download()]) assert.equal(one.status, 'ready') assert.equal(two.status, 'ready') assert.equal(http.downloadRanges.length - rangeCount, 1) const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.2-Setup.exe')) assert.equal(sha256(cached), sha256(secondBytes)) } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('install cancellation, launch failure marker cleanup, and success ordering', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-install-')) try { const bytes = makeBytes(256 * 1024, 61) http.latest = manifestFor(http.origin, '1.0.2', bytes) http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes) const fixture = createManagerFixture(root, http.origin, { identity: 'install' }) assert.equal((await fixture.manager.check()).status, 'available') fixture.controls.beforeResult = false const cancelled = await fixture.manager.install() assert.equal(cancelled.status, 'ready') assert.equal(fixture.installer.launchCount, 0) assert.equal(fixture.controls.failedCalls, 0) assert.equal((await fixture.manager.download()).status, 'ready') fixture.controls.beforeResult = true fixture.installer.launchError = new Error('spawn failed') const failed = await fixture.manager.install() assert.equal(failed.status, 'error') assert.equal(fixture.controls.failedCalls, 1) const cleared = await fixture.persistence.load() assert.equal(cleared.unfinishedVersion, undefined) assert.equal(cleared.target, undefined) assert.equal((await fixture.manager.check()).status, 'ready') fixture.installer.launchError = null let markerAtLaunch: string | undefined fixture.installer.onLaunch = async () => { markerAtLaunch = (await fixture.persistence.load()).unfinishedVersion } const installed = await fixture.manager.install() assert.equal(installed.status, 'installing') assert.equal(markerAtLaunch, '1.0.2') assert.equal(fixture.installer.validateCount >= 2, true) assert.equal(fixture.installer.verifyCount >= 2, true) assert.equal(fixture.installer.launchCount, 2) assert.equal(fixture.controls.quitCalls, 1) const retained = await fixture.persistence.load() assert.equal(retained.unfinishedVersion, '1.0.2') assert.equal(retained.target?.sha256, sha256(bytes)) const afterCommittedDismiss = await fixture.manager.dismiss() assert.equal(afterCommittedDismiss.status, 'installing') assert.equal(afterCommittedDismiss.dismissedVersion, undefined) const afterCommittedInstall = await fixture.manager.install() assert.equal(afterCommittedInstall.status, 'installing') assert.equal(fixture.installer.launchCount, 2) assert.equal(fixture.controls.beforeCalls, 3) } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('confirmation pending blocks dismiss and repeated install', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-confirm-lock-')) try { const bytes = makeBytes(128 * 1024, 67) http.latest = manifestFor(http.origin, '1.0.2', bytes) http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes) const fixture = createManagerFixture(root, http.origin, { identity: 'confirm-lock' }) assert.equal((await fixture.manager.check()).status, 'available') assert.equal((await fixture.manager.download()).status, 'ready') let releaseConfirmation!: (accepted: boolean) => void fixture.controls.beforeGate = new Promise(resolve => { releaseConfirmation = resolve }) const firstInstall = fixture.manager.install() await waitFor(() => fixture.controls.beforeCalls === 1) const dismissed = await fixture.manager.dismiss() assert.equal(dismissed.status, 'ready') assert.equal(dismissed.dismissedVersion, undefined) const repeatedInstall = fixture.manager.install() assert.equal(fixture.installer.launchCount, 0) assert.equal(fixture.controls.beforeCalls, 1) releaseConfirmation(false) const [firstState, repeatedState] = await Promise.all([firstInstall, repeatedInstall]) assert.equal(firstState.status, 'ready') assert.equal(repeatedState.status, 'ready') assert.equal(fixture.installer.launchCount, 0) assert.equal(fixture.controls.failedCalls, 0) assert.equal((await fixture.persistence.load()).dismissedVersion, undefined) } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('committed installer blocks recovery after quit failure', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-committed-')) try { const bytes = makeBytes(128 * 1024, 73) http.latest = manifestFor(http.origin, '1.0.2', bytes) http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes) const fixture = createManagerFixture(root, http.origin, { identity: 'committed' }) assert.equal((await fixture.manager.check()).status, 'available') assert.equal((await fixture.manager.download()).status, 'ready') fixture.controls.quitError = new Error('simulated quit failure') const committed = await fixture.manager.install() assert.equal(committed.status, 'installing') assert.match(committed.error ?? '', /必须退出 YCode/) assert.equal(fixture.installer.launchCount, 1) assert.equal(fixture.controls.beforeCalls, 1) assert.equal(fixture.controls.quitCalls, 1) assert.equal(fixture.controls.failedCalls, 0) const retained = await fixture.persistence.load() assert.equal(retained.unfinishedVersion, '1.0.2') assert.equal(retained.target?.version, '1.0.2') const requestCount = http.requests.length const dismissed = await fixture.manager.dismiss() const checked = await fixture.manager.check() const downloaded = await fixture.manager.download() const checkedAndDownloaded = await fixture.manager.checkAndDownload() const repeated = await fixture.manager.install() for (const state of [dismissed, checked, downloaded, checkedAndDownloaded, repeated]) { assert.equal(state.status, 'installing') assert.match(state.error ?? '', /必须退出 YCode/) } assert.equal(http.requests.length, requestCount) assert.equal(fixture.installer.launchCount, 1) assert.equal(fixture.controls.beforeCalls, 1) assert.equal(fixture.controls.quitCalls, 1) assert.equal(fixture.controls.failedCalls, 0) assert.equal((await fixture.persistence.load()).unfinishedVersion, '1.0.2') } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('corrupt cache is revalidated and rejected', async () => { const http = await createHttpFixture() const root = await mkdtemp(join(tmpdir(), 'ycode-update-cache-')) try { const bytes = makeBytes(128 * 1024, 71) http.latest = manifestFor(http.origin, '1.0.1', bytes) http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes) const fixture = createManagerFixture(root, http.origin, { identity: 'cache' }) assert.equal((await fixture.manager.check()).status, 'available') assert.equal((await fixture.manager.download()).status, 'ready') await writeFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'), Buffer.from('tampered')) const checked = await fixture.manager.check() assert.equal(checked.status, 'available') const retried = await fixture.manager.download() assert.equal(retried.status, 'ready') } finally { await http.close() await rm(root, { recursive: true, force: true }) } }) test('unfinished marker clears after target version is installed', async () => { const root = await mkdtemp(join(tmpdir(), 'ycode-update-marker-')) try { const persistence = new FileUpdatePersistence(join(root, 'state.json')) await persistence.save({ schemaVersion: 1, unfinishedVersion: '1.0.0', target: { version: '1.0.0', size: 10, sha256: 'a'.repeat(64), manifestUrl: 'http://127.0.0.1/releases/latest.json' } }) const manager = new UpdateManager({ currentVersion: '1.0.0', manifestUrl: 'http://127.0.0.1/releases/latest.json', cacheDirectory: join(root, 'cache'), logFile: join(root, 'updater.log'), persistence, publicKey: async () => publicPem, supported: true, installer: new FakeInstaller(), beforeInstall: async () => false, quitForUpdate: async () => undefined }) const state = await manager.getState() assert.equal(state.unfinishedVersion, undefined) const persisted = await persistence.load() assert.equal(persisted.target, undefined) } finally { await rm(root, { recursive: true, force: true }) } }) test('log redaction removes URL credentials and query secrets', () => { const sanitized = sanitizeLogText('GET http://user:password@example.test/a?token=secret Authorization: abc123') assert.equal(sanitized.includes('password'), false) assert.equal(sanitized.includes('secret'), false) assert.equal(sanitized.includes('abc123'), false) }) async function main(): Promise { let failures = 0 for (const [name, run] of tests) { try { await run() console.log(`ok - ${name}`) } catch (error) { failures += 1 console.error(`not ok - ${name}`) console.error(error) } } console.log(`\n${tests.length - failures}/${tests.length} updater backend tests passed`) if (failures > 0) process.exitCode = 1 } void main()