hermesBridge.test.ts 6.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,writeFileSync,rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join,resolve} from 'node:path'
  6. import request from 'supertest'
  7. import {HermesBridgeManager} from '../../src/server/hermesBridge'
  8. import type {ServerConfig} from '../../src/server/config'
  9. import {createAuthenticatedApplication,createUserAuthenticatedApplication} from './authenticatedApplication'
  10. let home:string,config:ServerConfig
  11. beforeEach(()=>{
  12. home=mkdtempSync(join(tmpdir(),'yaoyao-bridge-manager-'));writeFileSync(join(home,'config.yaml'),'{}')
  13. config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),home,allowedHosts:new Set(),mediaRoot:home,attachmentsRoot:home,imagesRoot:home,mediaOwner:'fixture',allowInsecureLan:false,insecureLan:false,production:false}
  14. })
  15. afterEach(()=>{rmSync(home,{recursive:true,force:true});vi.restoreAllMocks()})
  16. function fixture(){
  17. let disk:any[]=[{profile:'default',exists:true,valid:true,enabled:true,installedVersion:'1.1.0',fingerprint:'old',filesCurrent:false},{profile:'writer',exists:true,valid:true,enabled:false,disabled:true,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true}]
  18. let live:any={ready:true,native_tools:true,in_process:true,computer_runtime_version:2,plugin_version:'1.1.0',plugin_fingerprint:'old'}
  19. let idle=true,gate:Promise<void>|undefined
  20. const run=vi.fn(async(_python:string,args:string[])=>{
  21. if(args.includes('--check'))return disk.map(row=>JSON.stringify(row)).join('\n')
  22. await gate
  23. const profile=args[args.indexOf('--profile')+1]
  24. disk=disk.map(row=>row.profile===profile?{...row,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true,enabled:true,disabled:false}:row)
  25. return JSON.stringify({profile,backup:join(home,'backups',profile),disabled:false})
  26. })
  27. const session={request:vi.fn(async(path:string)=>({status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'},{name:'writer'}]}:live))}))}
  28. const manager=new HermesBridgeManager(config,session,{home,python:process.execPath,assetsRoot:resolve('.'),run,isIdle:()=>idle})
  29. return {manager,run,session,setLive:(value:any)=>{live=value},ready:()=>{live={...live,plugin_fingerprint:'new',plugin_version:'1.2.0'}},busy:()=>{idle=false},wait:(value:Promise<void>)=>{gate=value}}
  30. }
  31. it('distinguishes stale files, disabled Profile, pending restart and the actually loaded plugin',async()=>{
  32. const f=fixture()
  33. const before=await f.manager.status();expect(before.profiles.map(p=>p.state)).toEqual(['outdated','disabled'])
  34. expect(before.profiles[1].canInstall).toBe(false)
  35. await expect(f.manager.install({profile:'writer',enable:true})).rejects.toMatchObject({code:'hermes_bridge_host_entry_required'})
  36. await f.manager.install({profile:'default'})
  37. const installed=await f.manager.install({profile:'writer',enable:true})
  38. expect(f.run.mock.calls.at(-2)?.[1]).toContain('--enable')
  39. expect(installed.status.profiles.find(p=>p.profile==='writer')?.state).toBe('restart-required')
  40. expect(installed.status.profiles.find(p=>p.profile==='default')?.state).toBe('restart-required')
  41. f.ready();expect((await f.manager.status()).profiles.find(p=>p.profile==='writer')?.state).toBe('ready')
  42. expect(installed.backup).toBe(join(home,'backups','writer'))
  43. })
  44. it('prevents concurrent installs and rejects path injection, unknown profiles and installation during work',async()=>{
  45. const f=fixture();let release!:()=>void
  46. f.wait(new Promise<void>(done=>{release=done}))
  47. const pending=f.manager.install({profile:'default'})
  48. await vi.waitFor(()=>expect(f.run.mock.calls.some(([,args])=>!args.includes('--check'))).toBe(true))
  49. expect(f.manager.idleForUpdate).toBe(false)
  50. await expect(f.manager.install({profile:'writer'})).rejects.toMatchObject({code:'hermes_bridge_install_busy'})
  51. release();await pending
  52. expect(f.manager.idleForUpdate).toBe(true)
  53. await expect(f.manager.install({profile:'../other'})).rejects.toMatchObject({code:'invalid_bridge_install_request'})
  54. await expect(f.manager.install({profile:'default',home:'/wrong'})).rejects.toMatchObject({code:'invalid_bridge_install_request'})
  55. await expect(f.manager.install({profile:'missing'})).rejects.toMatchObject({code:'hermes_bridge_profile_missing'})
  56. f.busy();await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_bridge_tasks_running'})
  57. })
  58. it('checks remote Hermes but never invokes the local installer for it',async()=>{
  59. const f=fixture(),run=vi.fn()
  60. const manager=new HermesBridgeManager({...config,upstream:new URL('https://hermes.example')},f.session,{home,python:process.execPath,assetsRoot:resolve('.'),run})
  61. const status=await manager.status();expect(status.local).toBe(false);expect(status.profiles.every(p=>!p.canInstall)).toBe(true)
  62. await expect(manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_bridge_install_unavailable'})
  63. expect(run).not.toHaveBeenCalled()
  64. })
  65. it('exposes only admin/CSRF-protected management endpoints and returns the verified status after install',async()=>{
  66. const f=fixture(),admin=createAuthenticatedApplication({config,hermesBridge:f.manager})
  67. try{
  68. const api=request.agent(admin.app.callback()),host='127.0.0.1:15300'
  69. await api.get('/api/app/admin/hermes-bridge').set('Host',host).expect(200)
  70. await api.post('/api/app/admin/hermes-bridge/install').set('Host',host).send({profile:'default'}).expect(403)
  71. const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
  72. const response=await api.post('/api/app/admin/hermes-bridge/install').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({profile:'default'}).expect(200)
  73. expect(response.body.status.profiles[0].state).toBe('restart-required')
  74. }finally{admin.close()}
  75. const user=createUserAuthenticatedApplication({config,hermesBridge:f.manager})
  76. try{await request(user.app.callback()).get('/api/app/admin/hermes-bridge').set('Host','127.0.0.1:15300').expect(403)}finally{user.close()}
  77. })
  78. it('does not report an offline Hermes as loaded or expose installer diagnostic secrets',async()=>{
  79. const f=fixture()
  80. await f.manager.install({profile:'default'})
  81. f.session.request.mockRejectedValue(new Error('offline'))
  82. const state=await f.manager.status()
  83. expect(state.profiles[0].state).toBe('unavailable')
  84. expect(state.profiles[0].message).toContain('连接和权限')
  85. f.run.mockRejectedValue(new Error('sensitive diagnostic must stay private'))
  86. await expect(f.manager.install({profile:'default'})).rejects.not.toThrow('sensitive diagnostic')
  87. })