serviceInstance.test.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import Koa from 'koa'
  7. import request from 'supertest'
  8. import { acquireServiceInstance } from '../../src/server/serviceInstance'
  9. import { DesktopActivation } from '../../src/server/desktopActivation'
  10. let home: string
  11. let instances: ReturnType<typeof acquireServiceInstance>[]
  12. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'yaoyao-service-lock-')); instances = [] })
  13. afterEach(() => { for (const instance of instances) instance.release(); rmSync(home, { recursive: true, force: true }) })
  14. const acquire = (desktop = true) => { const instance = acquireServiceInstance(home, '0.3.32', desktop); instances.push(instance); return instance }
  15. describe('service ownership', () => {
  16. it('inspection never activates Hermes, and activation requires the private local capability', async () => {
  17. const instance = acquire(false), launch = vi.fn(), activation = new DesktopActivation(home, true, launch)
  18. const app = new Koa().use(instance.middleware(async () => {}, () => true, undefined, activation))
  19. const api = request(app.callback()), auth = { 'x-yaoyao-desktop-token': instance.record.token }
  20. activation.start()
  21. const inspection = await api.get('/desktop/service').set(auth).expect(200)
  22. expect(inspection.body.activationRequired).toBe(true)
  23. await api.post('/desktop/service/activate').expect(403)
  24. await api.post('/desktop/service/activate').set(auth).set('Origin', 'http://localhost').expect(403)
  25. await api.get('/desktop/service/activate').set(auth).expect(405)
  26. expect(launch).not.toHaveBeenCalled()
  27. await api.post('/desktop/service/quiesce').set(auth).expect(200)
  28. await api.post('/desktop/service/activate').set(auth).expect(503)
  29. await api.delete('/desktop/service/quiesce').set(auth).expect(200)
  30. await api.post('/desktop/service/activate').set(auth).expect(200)
  31. await api.post('/desktop/service/activate').set(auth).expect(200)
  32. expect(launch).toHaveBeenCalledTimes(1)
  33. const activated = await api.get('/desktop/service').set(auth).expect(200)
  34. expect(activated.body.activationRequired).toBe(false)
  35. instance.beginShutdown()
  36. await api.post('/desktop/service/activate').set(auth).expect(503)
  37. })
  38. it('rejects new application requests during shutdown without calling it an update',async()=>{
  39. const instance=acquire(false)
  40. const app=new Koa().use(instance.middleware(async()=>{})).use(ctx=>{ctx.body={ok:true}})
  41. await request(app.callback()).post('/api/app/conversations').expect(200)
  42. instance.beginShutdown()
  43. const blocked=await request(app.callback()).post('/api/app/conversations').expect(503)
  44. expect(blocked.body.code).toBe('service_stopping')
  45. const state=await request(app.callback()).get('/desktop/service').set('x-yaoyao-desktop-token',instance.record.token).expect(200)
  46. expect(state.body.stopping).toBe(true)
  47. expect(state.body.quiesced).toBe(false)
  48. })
  49. it('excludes a second process connection and releases ownership explicitly', () => {
  50. const first = acquire()
  51. expect(() => acquire()).toThrow('另一个')
  52. first.release()
  53. const next = acquire()
  54. expect(next.record.instanceId).not.toBe(first.record.instanceId)
  55. first.release()
  56. expect(JSON.parse(readFileSync(join(home, 'service-instance.json'), 'utf8')).instanceId).toBe(next.record.instanceId)
  57. })
  58. it('uses a private capability and exact local service identity, not a public port probe', async () => {
  59. const instance = acquire();instance.publish('http://127.0.0.1:12345')
  60. let stops = 0
  61. const app = new Koa().use(instance.middleware(async () => { stops++ }))
  62. await request(app.callback()).get('/desktop/service').expect(403)
  63. await request(app.callback()).get('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).set('Origin', 'http://evil.test').expect(403)
  64. const result = await request(app.callback()).get('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(200)
  65. expect(result.body).toMatchObject({ protocol: 1, pid: process.pid, instanceId: instance.record.instanceId })
  66. expect(result.body.token).toBeUndefined()
  67. await request(app.callback()).delete('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(200)
  68. await new Promise(resolve => setImmediate(resolve))
  69. expect(stops).toBe(1)
  70. })
  71. it('refuses stopping an externally managed CLI service', async () => {
  72. const instance = acquire(false)
  73. const app = new Koa().use(instance.middleware(async () => { throw new Error('must not stop') }))
  74. await request(app.callback()).delete('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(405)
  75. })
  76. it('admits updates only when idle and blocks application traffic until verification commits', async () => {
  77. const instance = acquire(false)
  78. let busy = true, writes = 0
  79. const app = new Koa().use(instance.middleware(async () => {}, () => !busy)).use(ctx => { writes++; ctx.body = { ok: true } })
  80. const api = request(app.callback()), auth = { 'x-yaoyao-desktop-token': instance.record.token }
  81. await api.post('/desktop/service/quiesce').expect(403)
  82. await api.post('/desktop/service/quiesce').set(auth).expect(409)
  83. await api.post('/api/app/conversations').expect(200)
  84. busy = false
  85. await api.post('/desktop/service/quiesce').set(auth).expect(200)
  86. await api.post('/api/app/conversations').expect(503)
  87. expect(writes).toBe(1)
  88. mkdirSync(join(home, 'updates')); writeFileSync(join(home, 'updates', 'transition.json'), '{}')
  89. await api.delete('/desktop/service/quiesce').set(auth).expect(405)
  90. await api.get('/healthz').expect(200)
  91. rmSync(join(home, 'updates', 'transition.json'))
  92. await api.delete('/desktop/service/quiesce').set(auth).expect(200)
  93. await api.post('/api/app/conversations').expect(200)
  94. })
  95. it('expires a preparation lease if the updater exits before recording a transaction', async () => {
  96. const instance = acquire(false), now = Date.now()
  97. const clock = vi.spyOn(Date, 'now').mockReturnValue(now)
  98. try {
  99. const app = new Koa().use(instance.middleware(async () => {})).use(ctx => { ctx.body = { ok: true } })
  100. const api = request(app.callback())
  101. await api.post('/desktop/service/quiesce').set('x-yaoyao-desktop-token', instance.record.token).expect(200)
  102. await api.post('/api/app/conversations').expect(503)
  103. clock.mockReturnValue(now + 15001)
  104. await api.post('/api/app/conversations').expect(200)
  105. } finally { clock.mockRestore() }
  106. })
  107. })