| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- import { test } from 'node:test'
- import assert from 'node:assert/strict'
- import { mkdtemp, rm, readdir, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { createHash } from 'node:crypto'
- import { DesktopUpdateManager, githubDownload } from './update-manager.mjs'
- const hash = bytes => createHash('sha256').update(bytes).digest('hex')
- const prefix = 'https://github.com/Lsamien/hermes-yaoyao/releases/download/v0.4.2/'
- const name = 'Yaoyao-0.4.2-arm64.dmg', data = Buffer.from('fixture-image')
- async function fixture(overrides = {}) {
- const home = await mkdtemp(join(tmpdir(), 'yaoyao-update-unit-'))
- const sums = Buffer.from(`${hash(data)} ${name}\n`)
- const release = { manifest: { webVersion: '0.4.2' }, commit: 'a'.repeat(40), notes: 'Notes', releasePageUrl: 'https://github.com/Lsamien/hermes-yaoyao/releases/tag/v0.4.2', assets: [
- { name, size: data.length, digest: `sha256:${hash(data)}`, url: prefix + name },
- { name: 'SHA256SUMS.txt', size: sums.length, digest: `sha256:${hash(sums)}`, url: prefix + 'SHA256SUMS.txt' },
- ] }
- const calls = [], verified = []
- const manager = new DesktopUpdateManager({ version: '0.4.1', arch: 'arm64', platform: 'darwin', cacheRoot: home,
- source: 'https://github.com/Lsamien/hermes-yaoyao.git', inspect: async () => release,
- compare: (a, b) => Number(a.split('.').at(-1)) - Number(b.split('.').at(-1)),
- fetchImpl: async url => { calls.push(url); return new Response(url.endsWith('.dmg') ? data : sums) },
- verifyImage: async path => { verified.push(path) }, ...overrides })
- return { manager, release, home, calls, verified, clean: () => rm(home, { recursive: true, force: true }) }
- }
- test('downloads, checks both digests and image, reuses verified cache and rejects later tampering', async () => {
- const f = await fixture()
- try {
- assert.equal((await f.manager.check()).available, true)
- assert.equal((await f.manager.download()).phase, 'ready')
- const path = await f.manager.verifiedFile()
- assert.equal(f.verified.length, 1)
- await f.manager.download()
- assert.equal(f.calls.filter(url => url.endsWith('.dmg')).length, 1)
- assert.equal(f.verified.length, 2)
- await writeFile(path, 'tampered')
- await assert.rejects(f.manager.verifiedFile(), /改变/)
- assert.equal(f.manager.snapshot().phase, 'failed')
- } finally { await f.clean() }
- })
- test('same version with a different commit and unsupported architectures never offer an install', async () => {
- const f = await fixture({ version: '0.4.2' })
- try {
- assert.equal((await f.manager.check()).available, false)
- f.manager.version = '0.4.1'; f.manager.arch = 'x64'
- assert.equal((await f.manager.check()).available, false)
- assert.match(f.manager.snapshot().message, /缺少/)
- assert.equal(f.calls.length, 0)
- } finally { await f.clean() }
- })
- test('corrupt or missing checksum, wrong size, and failed image validation never expose a file', async () => {
- for (const failure of ['digest', 'size', 'checksum', 'image']) {
- const f = await fixture()
- try {
- await f.manager.check()
- if (failure === 'digest') f.release.assets[0].digest = 'sha256:' + '0'.repeat(64)
- if (failure === 'size') f.release.assets[0].size++
- if (failure === 'checksum') f.manager.fetchImpl = async () => new Response('wrong-checksum')
- if (failure === 'image') f.manager.verifyImage = async () => { throw new Error('invalid dmg') }
- assert.equal((await f.manager.download()).phase, 'failed')
- await assert.rejects(f.manager.verifiedFile())
- assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
- } finally { await f.clean() }
- }
- })
- test('cancels a pending download, excludes duplicate work and permits retry', async () => {
- const f = await fixture()
- try {
- await f.manager.check()
- const original = f.manager.fetchImpl
- let started
- const entered = new Promise(resolve => { started = resolve })
- f.manager.fetchImpl = async (url, options) => {
- if (!url.endsWith('.dmg')) return original(url, options)
- started()
- return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }))
- }
- const pending = f.manager.download(); await entered
- assert.equal((await f.manager.download()).phase, 'downloading')
- f.manager.cancel(); assert.equal((await pending).phase, 'cancelled')
- assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
- f.manager.fetchImpl = original
- assert.equal((await f.manager.download()).phase, 'ready')
- } finally { await f.clean() }
- })
- test('a failed check clears stale update availability and reports a retryable error', async () => {
- const f = await fixture()
- try {
- await f.manager.check()
- f.manager.inspect = async () => { throw new Error('GitHub rate limited') }
- const state = await f.manager.check()
- assert.equal(state.available, false); assert.equal(state.phase, 'failed')
- assert.match(state.error, /GitHub/)
- } finally { await f.clean() }
- })
- test('follows GitHub CDN redirects but rejects insecure or foreign hosts', async () => {
- let calls = 0
- const allowed = async () => ++calls === 1 ? new Response(null, { status: 302, headers: { Location: 'https://release-assets.githubusercontent.com/fixture' } }) : new Response(data)
- assert.equal((await githubDownload(prefix + name, allowed)).status, 200)
- for (const location of ['http://github.com/file', 'https://evil.example/file', 'http://127.0.0.1/file']) {
- await assert.rejects(githubDownload(prefix + name, async () => new Response(null, { status: 302, headers: { Location: location } })), /不受信任/)
- }
- })
|