update-manager.test.mjs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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/hermes-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/hermes-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/hermes-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('corrupt or missing checksum, wrong size, and failed image validation never expose a file', async () => {
  52. for (const failure of ['digest', 'size', 'checksum', 'image']) {
  53. const f = await fixture()
  54. try {
  55. await f.manager.check()
  56. if (failure === 'digest') f.release.assets[0].digest = 'sha256:' + '0'.repeat(64)
  57. if (failure === 'size') f.release.assets[0].size++
  58. if (failure === 'checksum') f.manager.fetchImpl = async () => new Response('wrong-checksum')
  59. if (failure === 'image') f.manager.verifyImage = async () => { throw new Error('invalid dmg') }
  60. assert.equal((await f.manager.download()).phase, 'failed')
  61. await assert.rejects(f.manager.verifiedFile())
  62. assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
  63. } finally { await f.clean() }
  64. }
  65. })
  66. test('cancels a pending download, excludes duplicate work and permits retry', async () => {
  67. const f = await fixture()
  68. try {
  69. await f.manager.check()
  70. const original = f.manager.fetchImpl
  71. let started
  72. const entered = new Promise(resolve => { started = resolve })
  73. f.manager.fetchImpl = async (url, options) => {
  74. if (!url.endsWith('.dmg')) return original(url, options)
  75. started()
  76. return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }))
  77. }
  78. const pending = f.manager.download(); await entered
  79. assert.equal((await f.manager.download()).phase, 'downloading')
  80. f.manager.cancel(); assert.equal((await pending).phase, 'cancelled')
  81. assert.deepEqual(await readdir(join(f.home, '0.4.2')), [])
  82. f.manager.fetchImpl = original
  83. assert.equal((await f.manager.download()).phase, 'ready')
  84. } finally { await f.clean() }
  85. })
  86. test('a failed check clears stale update availability and reports a retryable error', async () => {
  87. const f = await fixture()
  88. try {
  89. await f.manager.check()
  90. f.manager.inspect = async () => { throw new Error('GitHub rate limited') }
  91. const state = await f.manager.check()
  92. assert.equal(state.available, false); assert.equal(state.phase, 'failed')
  93. assert.match(state.error, /GitHub/)
  94. } finally { await f.clean() }
  95. })
  96. test('follows GitHub CDN redirects but rejects insecure or foreign hosts', async () => {
  97. let calls = 0
  98. const allowed = async () => ++calls === 1 ? new Response(null, { status: 302, headers: { Location: 'https://release-assets.githubusercontent.com/fixture' } }) : new Response(data)
  99. assert.equal((await githubDownload(prefix + name, allowed)).status, 200)
  100. for (const location of ['http://github.com/file', 'https://evil.example/file', 'http://127.0.0.1/file']) {
  101. await assert.rejects(githubDownload(prefix + name, async () => new Response(null, { status: 302, headers: { Location: location } })), /不受信任/)
  102. }
  103. })