hermesBotRelay.test.ts 3.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
  1. // @vitest-environment node
  2. import { describe, it, expect, vi } from 'vitest'
  3. import { Readable } from 'node:stream'
  4. import { EventEmitter } from 'node:events'
  5. import { hermesBotRelay } from '../../src/server/hermesBotRelay'
  6. const id = '11111111-1111-4111-8111-111111111111'
  7. function fixture(path: string, method = 'GET', body?: object) {
  8. const calls: {url: URL; options: RequestInit}[] = []
  9. const node = { id, transport: 'paired-web', secret: 'sealed' }
  10. const nodes: any = {
  11. store: { require: (owner: string) => { if(owner !== 'owner') throw new Error('forbidden'); return node }, get: () => node },
  12. target: vi.fn((owner: string) => {
  13. if (owner !== 'owner') throw new Error('forbidden')
  14. return {url: new URL('http://child:15300/node/'+id), pairedToken:'child-secret', client: {
  15. fetchImpl: async (url: URL, options: RequestInit) => {
  16. calls.push({url, options})
  17. return Response.json(path.endsWith('capabilities') ? {protocolVersion:1, csrfToken:'child-csrf'} : {state:'confirmed'})
  18. },
  19. }}
  20. }),
  21. }
  22. const auth: any = {require: () => ({id:'owner'}), isUserActive: () => true}
  23. const ctx: any = {path: '/api/app/hermes-bot/nodes/'+id+path, method, search:'',
  24. req: Readable.from(body ? [JSON.stringify(body)] : []), res: new EventEmitter(),
  25. get: (name: string) => ({'idempotency-key':'command-one','last-event-id':'cursor-one'}[name] || ''),
  26. is: () => true, set: vi.fn(),
  27. }
  28. return {nodes,auth,ctx,calls,run:()=>hermesBotRelay(nodes,auth,{issue:()=> 'primary-csrf'} as any)(ctx,async()=>{})}
  29. }
  30. describe('native Hermes Bot primary relay', () => {
  31. it('keeps the primary CSRF identity and relays only to paired child 15300', async () => {
  32. const t=fixture('/api/realtime/capabilities'); await t.run()
  33. expect(t.ctx.body.csrfToken).toBe('primary-csrf')
  34. expect(t.calls[0]!.url.port).toBe('15300')
  35. expect(t.calls[0]!.url.pathname).toBe('/node/'+id+'/api/realtime/capabilities')
  36. expect(t.calls[0]!.options.headers).toMatchObject({Authorization:'Bearer child-secret'})
  37. expect(JSON.stringify(t.ctx.body)).not.toContain('child-secret')
  38. })
  39. it('uses the native child entry only for explicit native chat routes', async () => {
  40. const t=fixture('/api/realtime/capabilities')
  41. t.ctx.path=t.ctx.path.replace('/api/realtime','/native/api/realtime')
  42. await t.run()
  43. expect(t.calls[0]!.url.pathname).toBe('/node/'+id+'/api/hermes-bot/api/realtime/capabilities')
  44. })
  45. it('preserves receipt identity for prompt submission and forbids redirects', async () => {
  46. const frame={method:'prompt.submit',params:{text:'hello'}}
  47. const t=fixture('/api/realtime/channels/'+id+'/commands','POST',frame); await t.run()
  48. expect(t.calls[0]!.options.redirect).toBe('error')
  49. expect(t.calls[0]!.options.headers).toMatchObject({'Idempotency-Key':'command-one'})
  50. expect(JSON.parse(Buffer.from(t.calls[0]!.options.body as Uint8Array).toString())).toEqual(frame)
  51. })
  52. it('rejects another primary account and non-paired legacy nodes before contacting upstream', async () => {
  53. const t=fixture('/api/profiles'); t.auth.require=()=>({id:'other'})
  54. await expect(t.run()).rejects.toThrow('forbidden'); expect(t.calls).toHaveLength(0)
  55. t.auth.require=()=>({id:'owner'}); t.nodes.store.require=()=>({transport:'legacy'})
  56. await expect(t.run()).rejects.toThrow('15300'); expect(t.calls).toHaveLength(0)
  57. })
  58. it.each(['/api/app/agents','/api/auth/login','/api/profiles/new'])('refuses unrelated or configuration route %s', async path => {
  59. const t=fixture(path,'POST',{}); await expect(t.run()).rejects.toThrow('不支持'); expect(t.calls).toHaveLength(0)
  60. })
  61. })