828 lines
33 KiB
TypeScript
828 lines
33 KiB
TypeScript
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> | void]> = []
|
|
const test = (name: string, run: () => Promise<void> | void): void => {
|
|
tests.push([name, run])
|
|
}
|
|
|
|
const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
|
|
async function waitFor(predicate: () => boolean | Promise<boolean>, timeout = 5_000): Promise<void> {
|
|
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<string, Buffer>
|
|
requests: string[]
|
|
downloadRanges: Array<string | null>
|
|
redirect: 'none' | 'same-origin' | 'cross-origin'
|
|
crossOrigin: string
|
|
oversizeManifest: boolean
|
|
interruptNextDownload: boolean
|
|
chunkSize: number
|
|
chunkDelayMs: number
|
|
downloadStarted: number
|
|
origin: string
|
|
close: () => Promise<void>
|
|
}
|
|
|
|
async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise<void> {
|
|
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<void>(resolve => response.once('drain', resolve))
|
|
if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs)
|
|
}
|
|
if (!response.destroyed) response.end()
|
|
}
|
|
|
|
async function createHttpFixture(): Promise<HttpFixture> {
|
|
let server: ReturnType<typeof createServer>
|
|
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<void>((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<void>((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<void>) | null = null
|
|
|
|
async validateEnvironment(_requiredBytes?: number): Promise<InstallEnvironment> {
|
|
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<void> {
|
|
this.verifyCount += 1
|
|
}
|
|
|
|
async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise<void> {
|
|
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<boolean> | 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<string, unknown>
|
|
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<void> {
|
|
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()
|