host-manager.test.mjs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import {test} from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {mkdtemp,readFile,writeFile,stat,rm} from 'node:fs/promises'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {createServer} from 'node:http'
  7. import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
  8. import {DesktopHostManager} from './host-manager.mjs'
  9. import {parseDesktopHostConfiguration} from './host-config.mjs'
  10. const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))
  11. test('computer configuration validation mirrors the runner rules',()=>{
  12. const base={protocol:1,serverURL:'https://yaoyao.example.com',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  13. assert.deepEqual(parseDesktopHostConfiguration(base),{...base,allowInsecureLan:false})
  14. assert.deepEqual(parseDesktopHostConfiguration({...base,serverURL:'http://127.0.0.1:15300'}).serverURL,'http://127.0.0.1:15300')
  15. assert.throws(()=>parseDesktopHostConfiguration({...base,protocol:2}),/协议不兼容/)
  16. assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300'}),/HTTPS/)
  17. assert.equal(parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300',allowInsecureLan:true}).allowInsecureLan,true)
  18. assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'https://example.com/path'}),/服务地址/)
  19. assert.throws(()=>parseDesktopHostConfiguration({...base,hostId:'not-a-uuid'}),/电脑编号/)
  20. assert.throws(()=>parseDesktopHostConfiguration({...base,token:'short'}),/电脑凭据/)
  21. })
  22. async function fixture(config){
  23. const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-host-')),key=randomBytes(32)
  24. const encrypt=value=>{const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv);const bytes=Buffer.concat([cipher.update(value),cipher.final()]);return Buffer.concat([iv,cipher.getAuthTag(),bytes])}
  25. const decrypt=bytes=>{const cipher=createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString()}
  26. const cores=[],handled=[]
  27. const manager=new DesktopHostManager({home,root:'/app',dataRoot:home,
  28. encrypt,decrypt,
  29. createCore:()=>{const core={id:randomUUID(),closed:false,results:[],exchanges:0,
  30. info(){core.exchanges++;return {id:core.id,name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[]}},
  31. takeResults(){return core.results.splice(0,core.results.length)},
  32. async handle(commands){handled.push(...commands??[])},
  33. async revoke(){core.id=randomUUID()},
  34. async close(){core.closed=true}}
  35. cores.push(core);return core}})
  36. const configPath=join(home,'desktop-host.json')
  37. if(config)await writeFile(configPath,JSON.stringify(config))
  38. return {manager,home,configPath,cores,handled}
  39. }
  40. test('concurrent first logins share one persistent installation identity',async()=>{
  41. const {manager,home}=await fixture()
  42. try{
  43. const managers=Array.from({length:20},()=>new DesktopHostManager(manager.options))
  44. const ids=await Promise.all(managers.map(item=>item.installIdentity()))
  45. assert.equal(new Set(ids).size,1,'one installation must not register as multiple computers')
  46. assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),ids[0])
  47. await manager.forget()
  48. assert.equal(await new DesktopHostManager(manager.options).installIdentity(),ids[0])
  49. assert.equal((await stat(join(home,'desktop-install-id'))).mode&0o077,0)
  50. }finally{await rm(home,{recursive:true,force:true})}
  51. })
  52. test('a damaged installation identity is not silently replaced by a new computer',async()=>{
  53. const {manager,home}=await fixture()
  54. try{
  55. await writeFile(join(home,'desktop-install-id'),'damaged')
  56. await assert.rejects(()=>manager.installIdentity(),/设备标识/)
  57. assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),'damaged')
  58. }finally{await rm(home,{recursive:true,force:true})}
  59. })
  60. test('imports an encrypted config, exchanges with the paired server and stops on revocation',async()=>{
  61. const token=randomBytes(32).toString('base64url'),hostId=randomUUID()
  62. const exchanges=[]
  63. const server=createServer((req,res)=>{
  64. if(!req.url.includes(`/api/desktop-host/v1/${hostId}/exchange`)){res.statusCode=404;res.end();return}
  65. if(req.headers.authorization!==`Bearer ${token}`||req.headers['x-desktop-host-protocol']!=='1'){res.statusCode=403;res.end(JSON.stringify({error:'unauthorized'}));return}
  66. let body='';req.on('data',chunk=>{body+=chunk});req.on('end',()=>{
  67. exchanges.push(JSON.parse(body))
  68. res.setHeader('content-type','application/json')
  69. res.end(JSON.stringify(exchanges.length===1?{commands:[{id:randomUUID(),deadline:Date.now()+30000,mode:'local',owner:'a'.repeat(64),resource:'local',profile:'persistent',operation:'open'}]}:{commands:[]}))
  70. })
  71. })
  72. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  73. const {manager,home,configPath,cores,handled}=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId,token})
  74. try{
  75. await manager.importFile(configPath)
  76. const mode=await stat(join(home,'desktop-host.enc'))
  77. assert.equal(mode.mode&0o077,0)
  78. await sleep(1500)
  79. assert.ok(cores.length>=1)
  80. assert.equal(cores[0].closed,false)
  81. assert.equal(exchanges.length>=2,true)
  82. assert.equal(exchanges[0].host.name,'测试 Mac')
  83. assert.equal(handled.length,1)
  84. assert.equal(handled[0].operation,'open')
  85. assert.match(manager.state,/已连接/)
  86. await manager.forget()
  87. assert.equal(cores[0].closed,true)
  88. assert.equal(manager.state,'未配置电脑')
  89. await assert.rejects(()=>readFile(join(home,'desktop-host.enc')),{code:'ENOENT'})
  90. }finally{
  91. await manager.stop().catch(()=>{})
  92. server.close();server.closeAllConnections?.()
  93. }
  94. })
  95. test('a revoked token stops the loop with a clear state instead of retrying',async()=>{
  96. const token=randomBytes(32).toString('base64url')
  97. let requests=0
  98. const server=createServer((req,res)=>{requests++;res.statusCode=403;res.end(JSON.stringify({error:'revoked'}))})
  99. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  100. const {manager,configPath,cores}=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId:randomUUID(),token})
  101. try{
  102. await manager.importFile(configPath)
  103. await sleep(1200)
  104. assert.match(manager.state,/授权已失效/)
  105. assert.equal(cores[0].closed,true)
  106. const settled=requests
  107. await sleep(1200)
  108. assert.equal(requests,settled)
  109. }finally{
  110. await manager.stop().catch(()=>{})
  111. server.close();server.closeAllConnections?.()
  112. }
  113. })
  114. test('network failures back off but keep retrying',async()=>{
  115. const token=randomBytes(32).toString('base64url')
  116. const {manager,home,configPath,cores}=await fixture({protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token})
  117. try{
  118. await manager.importFile(configPath)
  119. await sleep(900)
  120. assert.match(manager.state,/正在重试/)
  121. assert.equal(cores[0].closed,false)
  122. await manager.stop()
  123. assert.equal(cores[0].closed,true)
  124. }finally{
  125. await rm(home,{recursive:true,force:true})
  126. }
  127. })