windows.test.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { mkdtemp, writeFile, readFile, rm, symlink } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { randomUUID, createHash } from 'node:crypto'
  7. import { EventEmitter } from 'node:events'
  8. import { DesktopCredentials } from './credentials.mjs'
  9. import { DesktopAutoUpdateManager } from './auto-update-manager.mjs'
  10. import { powershellArguments } from './windows-shell.mjs'
  11. import { execHostShell } from './host-files.mjs'
  12. import { windowsFrame, sameWindowsDisplay } from './windows-input.mjs'
  13. import { FileTransferFiles } from './file-transfer.mjs'
  14. import { windowsSigningOptions, verifyWindowsArtifacts } from '../scripts/windows-desktop-release.mjs'
  15. test('Windows credentials use the asynchronous OS provider and reject foreign/corrupt ciphertext', async () => {
  16. const raw = 'fixture-token', cipher = Buffer.from('opaque-encrypted-value')
  17. const store = { isAsyncEncryptionAvailable: async () => true, encryptStringAsync: async value => { assert.equal(value, raw); return cipher },
  18. decryptStringAsync: async value => { assert.deepEqual(value, cipher); return { result: raw } } }
  19. const credentials = new DesktopCredentials({ home: 'C:\\fixture', platform: 'win32', safeStorage: store })
  20. const encrypted = await credentials.encrypt(raw)
  21. assert.equal(encrypted.includes(raw), false)
  22. assert.equal(await credentials.decrypt(encrypted), raw)
  23. await assert.rejects(credentials.decrypt(Buffer.from('YAOYAO-RUNNER-KEYCHAIN-1\ninvalid')), /不属于 Windows/)
  24. store.isAsyncEncryptionAvailable = async () => false
  25. await assert.rejects(credentials.encrypt(raw), /加密暂不可用/)
  26. store.decryptStringAsync = async () => { throw new Error('DPAPI failure') }
  27. await assert.rejects(credentials.decrypt(encrypted), /重新登录/)
  28. })
  29. test('PowerShell transports quoted Unicode as UTF-16 without shell interpolation', () => {
  30. const command = 'Write-Output "你好 $env:USERNAME"; exit 7', args = powershellArguments(command)
  31. assert.equal(args.at(-2), '-EncodedCommand')
  32. const script = Buffer.from(args.at(-1), 'base64').toString('utf16le')
  33. assert.ok(script.includes(command)); assert.match(script, /UTF8Encoding/); assert.match(script, /LASTEXITCODE/)
  34. })
  35. test('Windows input invalidates screenshots after DPI, geometry or primary display changes', () => {
  36. const d = { id: 1, scaleFactor: 1.5, bounds: { x: 0, y: 0, width: 1280, height: 720 } }
  37. const physical = { x: 0, y: 0, width: 1920, height: 1080 }
  38. const frame = windowsFrame(d, { width: 1600, height: 900 }, physical)
  39. assert.equal(sameWindowsDisplay(frame, d, physical), true)
  40. assert.equal(sameWindowsDisplay(frame, { ...d, scaleFactor: 2 }, physical), false)
  41. assert.equal(sameWindowsDisplay(frame, { ...d, id: 2 }, physical), false)
  42. assert.equal(sameWindowsDisplay(frame, d, { ...physical, width: 2560 }), false)
  43. })
  44. test('NSIS installation is explicit and runs exactly once after cleanup', async t => {
  45. const driver = new EventEmitter(), events = []
  46. driver.checkForUpdates = async () => ({ isUpdateAvailable: true, updateInfo: { version: '1.1.0' } })
  47. driver.downloadUpdate = async () => { driver.emit('update-downloaded'); return ['setup.exe'] }
  48. driver.quitAndInstall = () => events.push('install')
  49. const manager = new DesktopAutoUpdateManager({ driver, platform: 'win32', version: '1.0.0', prepareInstall: async () => events.push('cleanup') })
  50. t.after(() => { manager.stopChecking(); clearTimeout(manager.restartTimer) })
  51. assert.equal(driver.autoInstallOnAppQuit, false)
  52. await manager.check(); await manager.download()
  53. assert.equal(manager.snapshot().phase, 'ready'); assert.equal(manager.stagingTimer, undefined)
  54. assert.deepEqual(events, [])
  55. await manager.install(); await manager.install()
  56. assert.deepEqual(events, ['cleanup', 'install'])
  57. })
  58. test('NSIS errors before install can retry, while dispatched installs stay fenced', async t => {
  59. const driver = new EventEmitter(), manager = new DesktopAutoUpdateManager({ driver, platform: 'win32', version: '1.0.0' })
  60. t.after(() => manager.stopChecking())
  61. manager.nativeStarted = true; manager.failure(new Error('checksum'))
  62. assert.equal(manager.snapshot().retryable, true); assert.equal(manager.nativeStarted, false)
  63. manager.nativeStarted = true; manager.installAttempted = true; manager.failure(new Error('installer failed'))
  64. assert.equal(manager.snapshot().retryable, false)
  65. })
  66. test('signed Windows builds fail closed without a certificate and publisher', async () => {
  67. const { validateConfiguration } = await import('app-builder-lib/out/util/config/config.js')
  68. const unsigned = windowsSigningOptions({})
  69. assert.equal(unsigned.forceCodeSigning, false); assert.equal(unsigned.win.signExecutable, false)
  70. await validateConfiguration(unsigned)
  71. assert.throws(() => windowsSigningOptions({ YAOYAO_WINDOWS_SIGNED: '1' }), /CSC_LINK/)
  72. const result = windowsSigningOptions({ YAOYAO_WINDOWS_SIGNED: '1', CSC_LINK: 'fixture.pfx', YAOYAO_WINDOWS_PUBLISHER: 'Fixture publisher' })
  73. assert.equal(result.forceCodeSigning, true); assert.equal(result.win.verifyUpdateCodeSignature, true)
  74. assert.equal(result.win.signExecutable, true); assert.equal(result.win.signtoolOptions.publisherName, 'Fixture publisher')
  75. await validateConfiguration(result)
  76. })
  77. test('Windows artifact verification detects corruption before delivery', async t => {
  78. const dir = await mkdtemp(join(tmpdir(), 'yaoyao-win-release-')); t.after(() => rm(dir, { recursive: true, force: true }))
  79. const name = 'Yaoyao-1.0.0-win-x64-setup.exe', bytes = Buffer.from('MZ-test-installer')
  80. await writeFile(join(dir, name), bytes); await writeFile(join(dir, name + '.blockmap'), 'map')
  81. await writeFile(join(dir, 'latest.yml'), JSON.stringify({ version: '1.0.0', files: [{ url: name, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] }))
  82. await verifyWindowsArtifacts(dir, '1.0.0')
  83. assert.match(await readFile(join(dir, 'SHA256SUMS-win-x64.txt'), 'utf8'), /setup\.exe/)
  84. await writeFile(join(dir, name), 'corrupt')
  85. await assert.rejects(verifyWindowsArtifacts(dir, '1.0.0'), /不匹配/)
  86. })
  87. test('native Windows PowerShell preserves Chinese, exit status and cancellation', { skip: process.platform !== 'win32' }, async t => {
  88. const root = await mkdtemp(join(tmpdir(), '夭夭-shell-')); t.after(() => rm(root, { recursive: true, force: true }))
  89. const result = await execHostShell(root, { command: "[Console]::WriteLine('你好,Windows'); exit 7" })
  90. assert.equal(result.stdout.trim(), '你好,Windows'); assert.equal(result.exitCode, 7)
  91. const controller = new AbortController()
  92. const pending = execHostShell(root, { command: 'Start-Sleep -Seconds 60' }, { signal: controller.signal })
  93. setTimeout(() => controller.abort(), 500)
  94. assert.equal((await pending).cancelled, true)
  95. const childCommand = "$p = Start-Process -FilePath $env:ComSpec -ArgumentList '/c ping -n 60 127.0.0.1 > nul' -WindowStyle Hidden -PassThru; [Console]::WriteLine($p.Id)"
  96. for (const wait of [false, true]) {
  97. const result = await execHostShell(root, { command: childCommand + (wait ? '; Start-Sleep -Seconds 60' : ''), timeoutMs: 3000 })
  98. assert.equal(result.timedOut, wait)
  99. const pid = Number(result.stdout.trim()); assert.ok(pid > 0, result.stderr)
  100. const alive = await execHostShell(root, { command: `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { exit 1 } else { exit 0 }` })
  101. assert.equal(alive.exitCode, 0, 'PowerShell 子进程在结束或超时后必须退出')
  102. }
  103. })
  104. test('chunked file writes preserve contents and reject directory links outside the root', async t => {
  105. const root = await mkdtemp(join(tmpdir(), 'yaoyao-win-files-')), outside = await mkdtemp(join(tmpdir(), 'yaoyao-win-outside-'))
  106. const files = new FileTransferFiles(root)
  107. t.after(async () => { await files.close(); await rm(root, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }) })
  108. const bytes = Buffer.from('中文附件'), transferId = randomUUID(), path = '中文 文件.txt'
  109. await files.call({ op: 'transfer-write-open', transferId, path, maxBytes: 1048576, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), overwrite: false })
  110. await files.call({ op: 'transfer-append', transferId, offset: 0, data: bytes.toString('base64') })
  111. await files.call({ op: 'transfer-finish', transferId })
  112. assert.equal(await readFile(join(root, path), 'utf8'), '中文附件')
  113. await symlink(outside, join(root, 'escape'), process.platform === 'win32' ? 'junction' : 'dir')
  114. await assert.rejects(files.path('escape/secret', true), /超出允许范围/)
  115. })