host-manager.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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 {HostEnvironmentReporter} from './host-environment.mjs'
  9. import {DesktopHostManager,remoteExchange} from './host-manager.mjs'
  10. import {parseDesktopHostConfiguration} from './host-config.mjs'
  11. const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))
  12. test('computer configuration validation mirrors the runner rules',()=>{
  13. const base={protocol:1,serverURL:'https://yaoyao.example.com',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  14. assert.deepEqual(parseDesktopHostConfiguration(base),{...base,allowInsecureLan:false})
  15. assert.deepEqual(parseDesktopHostConfiguration({...base,serverURL:'http://127.0.0.1:15300'}).serverURL,'http://127.0.0.1:15300')
  16. assert.throws(()=>parseDesktopHostConfiguration({...base,protocol:2}),/协议不兼容/)
  17. assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300'}),/HTTPS/)
  18. assert.equal(parseDesktopHostConfiguration({...base,serverURL:'http://192.168.1.4:15300',allowInsecureLan:true}).allowInsecureLan,true)
  19. assert.throws(()=>parseDesktopHostConfiguration({...base,serverURL:'https://example.com/path'}),/服务地址/)
  20. assert.throws(()=>parseDesktopHostConfiguration({...base,hostId:'not-a-uuid'}),/电脑编号/)
  21. assert.throws(()=>parseDesktopHostConfiguration({...base,token:'short'}),/电脑凭据/)
  22. })
  23. async function fixture(config,options={}){
  24. const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-host-')),key=randomBytes(32)
  25. 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])}
  26. 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()}
  27. const cores=[],handled=[]
  28. const manager=new DesktopHostManager({home,root:'/app',dataRoot:home,
  29. encrypt,decrypt,...options,
  30. createCore:()=>{const reporter=new HostEnvironmentReporter('/Users/fixture');const core={id:randomUUID(),closed:false,results:[],exchanges:0,
  31. info(){core.exchanges++;return {id:core.id,name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[],...reporter.fields()}},
  32. acceptCapabilities(capabilities){reporter.accept(capabilities)},
  33. takeResults(){return core.results.splice(0,core.results.length)},
  34. async handle(commands){handled.push(...commands??[])},
  35. async revoke(){core.id=randomUUID()},
  36. async close(){core.closed=true}}
  37. cores.push(core);return core}})
  38. const configPath=join(home,'desktop-host.json')
  39. if(config)await writeFile(configPath,JSON.stringify(config))
  40. return {manager,home,configPath,cores,handled}
  41. }
  42. test('concurrent first logins share one persistent installation identity',async()=>{
  43. const {manager,home}=await fixture()
  44. try{
  45. const managers=Array.from({length:20},()=>new DesktopHostManager(manager.options))
  46. const ids=await Promise.all(managers.map(item=>item.installIdentity()))
  47. assert.equal(new Set(ids).size,1,'one installation must not register as multiple computers')
  48. assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),ids[0])
  49. await manager.forget()
  50. assert.equal(await new DesktopHostManager(manager.options).installIdentity(),ids[0])
  51. if(process.platform!=='win32')assert.equal((await stat(join(home,'desktop-install-id'))).mode&0o077,0)
  52. }finally{await rm(home,{recursive:true,force:true})}
  53. })
  54. test('a damaged installation identity is not silently replaced by a new computer',async()=>{
  55. const {manager,home}=await fixture()
  56. try{
  57. await writeFile(join(home,'desktop-install-id'),'damaged')
  58. await assert.rejects(()=>manager.installIdentity(),/设备标识/)
  59. assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),'damaged')
  60. }finally{await rm(home,{recursive:true,force:true})}
  61. })
  62. test('device polling uses the injected login transport without cookies or redirects',async()=>{
  63. const config={protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  64. const requests=[]
  65. const f=await fixture(config,{fetchImpl:async(url,options)=>{
  66. requests.push({url,options})
  67. return Response.json({commands:[{id:'test-command'}],capabilities:{environmentMetadata:1}})
  68. }})
  69. try{
  70. await f.manager.importFile(f.configPath)
  71. clearTimeout(f.manager.timer)
  72. await f.manager.cycle();clearTimeout(f.manager.timer)
  73. assert.equal(requests.length,1)
  74. assert.equal(requests[0].url,`${config.serverURL}/api/desktop-host/v1/${config.hostId}/exchange`)
  75. const options=requests[0].options
  76. assert.equal(options.method,'POST')
  77. assert.equal(options.credentials,'omit')
  78. assert.equal(options.redirect,'error')
  79. assert.equal(options.headers.Authorization,`Bearer ${config.token}`)
  80. assert.equal(options.headers['x-desktop-host-protocol'],'1')
  81. assert.equal(JSON.parse(options.body).host.name,'测试 Mac')
  82. assert.match(f.manager.state,/已连接/)
  83. assert.deepEqual(f.handled,[{id:'test-command'}])
  84. }finally{await f.manager.stop();await rm(f.home,{recursive:true,force:true})}
  85. })
  86. test('injected transport stops polling on revoked authorization or incompatible protocol',async()=>{
  87. for(const status of [401,403,409]){
  88. const config={protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  89. let requests=0
  90. const f=await fixture(config,{fetchImpl:async()=>{requests++;return new Response('',{status})}})
  91. try{
  92. await f.manager.importFile(f.configPath);clearTimeout(f.manager.timer)
  93. await f.manager.cycle();await f.manager.cycle()
  94. assert.equal(requests,1)
  95. assert.equal(f.cores[0].closed,true)
  96. assert.equal(f.manager.timer,undefined)
  97. assert.match(f.manager.state,status===409?/协议不兼容/:/授权已失效/)
  98. }finally{await f.manager.stop();await rm(f.home,{recursive:true,force:true})}
  99. }
  100. })
  101. test('injected transport retries transient network errors',async()=>{
  102. const config={protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  103. let requests=0
  104. const f=await fixture(config,{fetchImpl:async()=>{
  105. if(++requests===1)throw new Error('EHOSTUNREACH')
  106. return Response.json({commands:[]})
  107. }})
  108. try{
  109. await f.manager.importFile(f.configPath);clearTimeout(f.manager.timer)
  110. await f.manager.cycle();clearTimeout(f.manager.timer)
  111. assert.match(f.manager.state,/正在重试/)
  112. assert.equal(f.cores[0].closed,false)
  113. assert.equal(f.manager.backoff,800)
  114. await f.manager.cycle();clearTimeout(f.manager.timer)
  115. assert.match(f.manager.state,/已连接/)
  116. assert.equal(f.manager.backoff,400)
  117. }finally{await f.manager.stop();await rm(f.home,{recursive:true,force:true})}
  118. })
  119. test('injected transport rejects oversized desktop responses and invalid JSON',async()=>{
  120. const config={serverURL:'https://yaoyao.example.com',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
  121. let cancelled=false
  122. await assert.rejects(()=>remoteExchange(config,{},async()=>new Response(new ReadableStream({
  123. start(controller){controller.enqueue(new Uint8Array(16*1024*1024+1))},
  124. cancel(){cancelled=true},
  125. }))),/桌面命令超过限制/)
  126. assert.equal(cancelled,true)
  127. await assert.rejects(()=>remoteExchange(config,{},async()=>new Response('not json')),SyntaxError)
  128. })
  129. test('imports an encrypted config, exchanges with the paired server and stops on revocation',async()=>{
  130. const token=randomBytes(32).toString('base64url'),hostId=randomUUID()
  131. const exchanges=[]
  132. const server=createServer((req,res)=>{
  133. if(!req.url.includes(`/api/desktop-host/v1/${hostId}/exchange`)){res.statusCode=404;res.end();return}
  134. if(req.headers.authorization!==`Bearer ${token}`||req.headers['x-desktop-host-protocol']!=='1'){res.statusCode=403;res.end(JSON.stringify({error:'unauthorized'}));return}
  135. let body='';req.on('data',chunk=>{body+=chunk});req.on('end',()=>{
  136. exchanges.push(JSON.parse(body))
  137. res.setHeader('content-type','application/json')
  138. res.end(JSON.stringify({capabilities:{desktopPlatforms:['darwin','win32']},...(exchanges.length===1?{commands:[{id:randomUUID(),deadline:Date.now()+30000,mode:'local',owner:'a'.repeat(64),resource:'local',profile:'persistent',operation:'open'}]}:{commands:[]})}))
  139. })
  140. })
  141. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  142. const {manager,home,configPath,cores,handled}=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId,token})
  143. try{
  144. await manager.importFile(configPath)
  145. const mode=await stat(join(home,'desktop-host.enc'))
  146. if(process.platform!=='win32')assert.equal(mode.mode&0o077,0)
  147. await sleep(1500)
  148. assert.ok(cores.length>=1)
  149. assert.equal(cores[0].closed,false)
  150. assert.equal(exchanges.length>=2,true)
  151. assert.equal(exchanges[0].host.name,'测试 Mac')
  152. assert.equal(handled.length,1)
  153. assert.equal(handled[0].operation,'open')
  154. assert.match(manager.state,/已连接/)
  155. await manager.forget()
  156. assert.equal(cores[0].closed,true)
  157. assert.equal(manager.state,'未配置电脑')
  158. await assert.rejects(()=>readFile(join(home,'desktop-host.enc')),{code:'ENOENT'})
  159. }finally{
  160. await manager.stop().catch(()=>{})
  161. server.close();server.closeAllConnections?.()
  162. }
  163. })
  164. test('a revoked token stops the loop with a clear state instead of retrying',async()=>{
  165. const token=randomBytes(32).toString('base64url')
  166. let requests=0
  167. const server=createServer((req,res)=>{requests++;res.statusCode=403;res.end(JSON.stringify({error:'revoked'}))})
  168. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  169. const {manager,configPath,cores}=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId:randomUUID(),token})
  170. try{
  171. await manager.importFile(configPath)
  172. await sleep(1200)
  173. assert.match(manager.state,/授权已失效/)
  174. assert.equal(cores[0].closed,true)
  175. const settled=requests
  176. await sleep(1200)
  177. assert.equal(requests,settled)
  178. }finally{
  179. await manager.stop().catch(()=>{})
  180. server.close();server.closeAllConnections?.()
  181. }
  182. })
  183. test('network failures back off but keep retrying',async()=>{
  184. const token=randomBytes(32).toString('base64url')
  185. const {manager,home,configPath,cores}=await fixture({protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token})
  186. try{
  187. await manager.importFile(configPath)
  188. await sleep(900)
  189. assert.match(manager.state,/正在重试/)
  190. assert.equal(cores[0].closed,false)
  191. await manager.stop()
  192. assert.equal(cores[0].closed,true)
  193. }finally{
  194. await rm(home,{recursive:true,force:true})
  195. }
  196. })
  197. test('negotiates metadata and retries in legacy format when the server stops supporting it',async()=>{
  198. const exchanges=[]
  199. const server=createServer((req,res)=>{let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=>{
  200. exchanges.push(JSON.parse(body));res.setHeader('content-type','application/json')
  201. if(exchanges.length===2){res.statusCode=400;res.end(JSON.stringify({error:'older server'}));return}
  202. res.end(JSON.stringify(exchanges.length===1?{commands:[],capabilities:{environmentMetadata:1}}:{commands:[]}))
  203. })})
  204. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  205. const f=await fixture({protocol:1,serverURL:`http://127.0.0.1:${server.address().port}`,hostId:randomUUID(),token:randomBytes(32).toString('base64url')})
  206. try{
  207. await f.manager.importFile(f.configPath)
  208. clearTimeout(f.manager.timer)
  209. for(let i=0;i<3;i++){await f.manager.cycle();clearTimeout(f.manager.timer)}
  210. assert.equal(exchanges[0].host.environment,undefined)
  211. assert.equal(exchanges[0].host.fileTransferVersion,undefined)
  212. assert.equal(exchanges[1].host.environment.homeDirectory,'/Users/fixture')
  213. assert.equal(exchanges[1].host.fileTransferVersion,1)
  214. assert.equal(exchanges[2].host.environment,undefined)
  215. assert.equal(exchanges[2].host.fileTransferVersion,undefined)
  216. }finally{await f.manager.stop();await new Promise(resolve=>server.close(resolve));await rm(f.home,{recursive:true,force:true})}
  217. })