realtimeApi.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import request from 'supertest'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. const roots: string[] = []
  10. const runtimes: ApplicationRuntime[] = []
  11. afterEach(() => {
  12. for (const runtime of runtimes.splice(0)) runtime.close()
  13. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  14. })
  15. describe('native Hermes chat and history are separated on 15300', () => {
  16. it('opens realtime chat while keeping non-Web history mutations read-only', async () => {
  17. const home = mkdtempSync(join(tmpdir(), 'yaoyao-native-history-'))
  18. roots.push(home)
  19. let upstreamCalls = 0, upstreamWrites = 0
  20. const config: ServerConfig = {
  21. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:9119'),
  22. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home,
  23. imagesRoot: home, mediaOwner: 'test', allowInsecureLan: false,
  24. insecureLan: false, production: false,
  25. }
  26. const runtime = createApplication({
  27. config,
  28. fetchImpl: (async (input, init) => {
  29. upstreamCalls += 1
  30. if (!['GET', 'HEAD'].includes(init?.method ?? 'GET')) upstreamWrites += 1
  31. const path = new URL(String(input)).pathname
  32. if (path === '/api/status') return Response.json({ auth_required: true })
  33. if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
  34. if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
  35. if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
  36. headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
  37. })
  38. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', ui_meta: {'hermes-bots': {chat: 'native-canonical'}} }] })
  39. if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'fixture-ticket' })
  40. if (path === '/api/sessions/session-web') return Response.json(init?.method === 'PATCH' ? { ok: true } : { id: 'session-web', source: 'web' })
  41. return Response.json({ method: init?.method ?? 'GET' })
  42. }) as typeof fetch,
  43. })
  44. runtimes.push(runtime)
  45. const agent = request.agent(runtime.app.callback())
  46. const origin = 'http://127.0.0.1:15300'
  47. const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:15300').expect(200)
  48. const setup = await agent.post('/api/app/setup').set('Host', '127.0.0.1:15300')
  49. .set('Origin', origin).set('X-CSRF-Token', bootstrap.body.csrfToken)
  50. .send({ username: 'owner', password: 'fixture-password' }).expect(200)
  51. const csrf = setup.body.csrfToken
  52. const owner = String(setup.body.user.id)
  53. const capabilities = await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(200)
  54. expect(capabilities.body.channels).toEqual(['chat'])
  55. const beforeWrites = upstreamCalls, beforeUpstreamWrites = upstreamWrites
  56. for (const path of ['/api/app/sessions/session-1', '/api/sessions/session-1']) {
  57. const response = await agent.patch(path).set('Host', '127.0.0.1:15300')
  58. .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '不能修改' }).expect(410)
  59. expect(response.body.code).toBe('native_sessions_read_only')
  60. }
  61. const pairing = runtime.pairings.create('hermes_device=session')
  62. const paired = runtime.pairings.claim({
  63. pairingID: pairing.id,
  64. secret: pairing.secret,
  65. deviceName: 'history-only fixture',
  66. })
  67. const pairedWrite = await request(runtime.app.callback())
  68. .patch(`/node/${paired.device.id}/api/sessions/session-1`)
  69. .set('Host', '127.0.0.1:15300')
  70. .set('Authorization', `Bearer ${paired.token}`)
  71. .send({ title: '不能修改' })
  72. .expect(410)
  73. expect(pairedWrite.body.code).toBe('native_sessions_read_only')
  74. expect(upstreamCalls).toBeGreaterThan(beforeWrites)
  75. expect(upstreamWrites).toBe(beforeUpstreamWrites)
  76. const unownedWeb = await agent.patch('/api/app/sessions/session-web?profile=default')
  77. .set('Host', '127.0.0.1:15300').set('Origin', origin).set('X-CSRF-Token', csrf)
  78. .send({ title: '不能靠 source 修改' }).expect(410)
  79. expect(unownedWeb.body.code).toBe('native_sessions_read_only')
  80. const ownershipGate = runtime.realtime as unknown as {
  81. canResume(profile: string, sessionID: string, jar?: unknown, owner?: string): Promise<boolean>
  82. }
  83. expect(await ownershipGate.canResume('default', 'session-web', undefined, owner)).toBe(false)
  84. expect(await ownershipGate.canResume('default', 'native-canonical', undefined, owner)).toBe(false)
  85. await agent.get('/api/app/hermes-bot/local/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(200)
  86. await request(runtime.app.callback()).get('/api/app/hermes-bot/local/api/realtime/capabilities').set('Host', '127.0.0.1:15300').expect(401)
  87. await agent.post('/api/app/hermes-bot/local/api/profiles').set('Host', '127.0.0.1:15300')
  88. .set('Origin', origin).set('X-CSRF-Token', csrf).send({}).expect(403)
  89. const deviceOwner = `device:${paired.device.id}`
  90. expect(await ownershipGate.canResume('default', 'session-web', undefined, deviceOwner)).toBe(false)
  91. runtime.chatCache!.store.recordRoute(deviceOwner, 'default', 'device-session', 'runtime-device')
  92. expect(await ownershipGate.canResume('default', 'device-session', undefined, deviceOwner)).toBe(true)
  93. expect(await ownershipGate.canResume('default', 'device-session', undefined, owner)).toBe(false)
  94. const anotherPairing = runtime.pairings.create('another_device=session')
  95. const anotherDevice = runtime.pairings.claim({
  96. pairingID: anotherPairing.id,
  97. secret: anotherPairing.secret,
  98. deviceName: 'another history fixture',
  99. })
  100. expect(await ownershipGate.canResume(
  101. 'default',
  102. 'device-session',
  103. undefined,
  104. `device:${anotherDevice.device.id}`,
  105. )).toBe(false)
  106. runtime.chatCache!.store.recordRoute(owner, 'default', 'session-web', 'runtime-web')
  107. runtime.chatCache!.store.recordCommand(owner, 'default', 'session-web', 'session.create', { source: 'ios' })
  108. expect(await ownershipGate.canResume('default', 'session-web', undefined, owner)).toBe(true)
  109. await agent.patch('/api/app/sessions/session-web?profile=default').set('Host', '127.0.0.1:15300')
  110. .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '可修改的聊天' }).expect(200)
  111. expect(upstreamWrites).toBe(beforeUpstreamWrites + 1)
  112. })
  113. })