update-manager.test.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { mkdtemp, rm, readdir, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { createHash } from 'node:crypto'
  7. import { DesktopUpdateManager, githubDownload } from './update-manager.mjs'
  8. const hash = bytes => createHash('sha256').update(bytes).digest('hex')
  9. const prefix = 'https://github.com/Lsamien/yaoyao/releases/download/v0.4.2/'
  10. const name = 'Yaoyao-0.4.2-arm64.dmg', data = Buffer.from('fixture-image')
  11. async function fixture(overrides = {}) {
  12. const home = await mkdtemp(join(tmpdir(), 'yaoyao-update-unit-'))
  13. const sums = Buffer.from(`${hash(data)} ${name}\n`)
  14. const release = { manifest: { webVersion: '0.4.2' }, commit: 'a'.repeat(40), notes: 'Notes', releasePageUrl: 'https://github.com/Lsamien/yaoyao/releases/tag/v0.4.2', assets: [
  15. { name, size: data.length, digest: `sha256:${hash(data)}`, url: prefix + name },
  16. { name: 'SHA256SUMS.txt', size: sums.length, digest: `sha256:${hash(sums)}`, url: prefix + 'SHA256SUMS.txt' },
  17. ] }
  18. const calls = [], verified = []
  19. const manager = new DesktopUpdateManager({ version: '0.4.1', arch: 'arm64', platform: 'darwin', cacheRoot: home,
  20. source: 'https://github.com/Lsamien/yaoyao.git', inspect: async () => release,
  21. compare: (a, b) => Number(a.split('.').at(-1)) - Number(b.split('.').at(-1)),
  22. fetchImpl: async url => { calls.push(url); return new Response(url.endsWith('.dmg') ? data : sums) },
  23. verifyImage: async path => { verified.push(path) }, ...overrides })
  24. return { manager, release, home, calls, verified, clean: () => rm(home, { recursive: true, force: true }) }
  25. }
  26. test('downloads, checks both digests and image, reuses verified cache and rejects later tampering', async () => {
  27. const f = await fixture()
  28. try {
  29. assert.equal((await f.manager.check()).available, true)
  30. assert.equal((await f.manager.download()).phase, 'ready')
  31. const path = await f.manager.verifiedFile()
  32. assert.equal(f.verified.length, 1)
  33. await f.manager.download()
  34. assert.equal(f.calls.filter(url => url.endsWith('.dmg')).length, 1)
  35. assert.equal(f.verified.length, 2)
  36. await writeFile(path, 'tampered')
  37. await assert.rejects(f.manager.verifiedFile(), /改变/)
  38. assert.equal(f.manager.snapshot().phase, 'failed')
  39. } finally { await f.clean() }
  40. })
  41. test('same version with a different commit and unsupported architectures never offer an install', async () => {
  42. const f = await fixture({ version: '0.4.2' })
  43. try {
  44. assert.equal((await f.manager.check()).available, false)
  45. f.manager.version = '0.4.1'; f.manager.arch = 'x64'
  46. assert.equal((await f.manager.check()).available, false)
  47. assert.match(f.manager.snapshot().message, /缺少/)
  48. assert.equal(f.calls.length, 0)
  49. } finally { await f.clean() }
  50. })
  51. test('Windows downloads an EXE using its platform checksum manifest', async () => {
  52. const assetName = 'Yaoyao-0.4.2-win-x64-setup.exe'
  53. const sums = Buffer.from(`${hash(data)} ${assetName}\n`)
  54. const f = await fixture({ platform: 'win32', arch: 'x64', fetchImpl: async url => new Response(url.endsWith('.exe') ? data : sums) })
  55. try {
  56. f.release.assets = [
  57. { name: assetName, url: prefix + assetName, size: data.length },
  58. { name: 'SHA256SUMS-win-x64.txt', url: prefix + 'SHA256SUMS-win-x64.txt', size: sums.length },
  59. ]
  60. assert.equal((await f.manager.check()).available, true)
  61. assert.equal((await f.manager.download()).phase, 'ready')
  62. assert.match(await f.manager.verifiedFile(), /win-x64-setup\.exe$/)
  63. } finally { await f.clean() }
  64. })
  65. test('corrupt or missing checksum, wrong size, and failed image validation never expose a file', async () => {
  66. for (const failure of ['digest', 'size', 'checksum', 'image']) {
  67. const f = await fixture()
  68. try {
  69. await f.manager.check()
  70. if (failure === 'digest') f.release.assets[0].digest = 'sha256:' + '0'.repeat(64)
  71. if (failure === 'size') f.release.assets[0].size++
  72. if (failure === 'checksum') f.manager.fetchImpl = async () => new Response('wrong-checksum')
  73. if (failure === 'image') f.manager.verifyImage = async () => { throw new Error('invalid dmg') }
  74. assert.equal((await f.manager.download()).phase, 'failed')
  75. await assert.rejects(f.manager.verifiedFile())
  76. assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
  77. } finally { await f.clean() }
  78. }
  79. })
  80. test('cancels a pending download, excludes duplicate work and permits retry', async () => {
  81. const f = await fixture()
  82. try {
  83. await f.manager.check()
  84. const original = f.manager.fetchImpl
  85. let started
  86. const entered = new Promise(resolve => { started = resolve })
  87. f.manager.fetchImpl = async (url, options) => {
  88. if (!url.endsWith('.dmg')) return original(url, options)
  89. started()
  90. return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }))
  91. }
  92. const pending = f.manager.download(); await entered
  93. assert.equal((await f.manager.download()).phase, 'downloading')
  94. f.manager.cancel(); assert.equal((await pending).phase, 'cancelled')
  95. assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
  96. f.manager.fetchImpl = original
  97. assert.equal((await f.manager.download()).phase, 'ready')
  98. } finally { await f.clean() }
  99. })
  100. test('a failed check clears stale update availability and reports a retryable error', async () => {
  101. const f = await fixture()
  102. try {
  103. await f.manager.check()
  104. f.manager.inspect = async () => { throw new Error('GitHub rate limited') }
  105. const state = await f.manager.check()
  106. assert.equal(state.available, false); assert.equal(state.phase, 'failed')
  107. assert.match(state.error, /GitHub/)
  108. } finally { await f.clean() }
  109. })
  110. test('follows GitHub CDN redirects but rejects insecure or foreign hosts', async () => {
  111. let calls = 0
  112. const allowed = async () => ++calls === 1 ? new Response(null, { status: 302, headers: { Location: 'https://release-assets.githubusercontent.com/fixture' } }) : new Response(data)
  113. assert.equal((await githubDownload(prefix + name, allowed)).status, 200)
  114. for (const location of ['http://github.com/file', 'https://evil.example/file', 'http://127.0.0.1/file']) {
  115. await assert.rejects(githubDownload(prefix + name, async () => new Response(null, { status: 302, headers: { Location: location } })), /不受信任/)
  116. }
  117. })