systemUpdateRoutes.test.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import request from 'supertest'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import type { ApplicationRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. import { systemUpdateRequestAllowed } from '../../src/server/routes.js'
  9. import { SystemUpdateManager } from '../../src/server/updateManager.js'
  10. import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
  11. const runtimes: ApplicationRuntime[] = []
  12. const roots: string[] = []
  13. afterEach(() => {
  14. for (const runtime of runtimes.splice(0)) runtime.close()
  15. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  16. })
  17. function cookieHeader(response: request.Response): string {
  18. const values = response.headers['set-cookie'] as unknown as string[]
  19. return values.map(value => value.split(';', 1)[0]).join('; ')
  20. }
  21. function json(value: unknown, init: ResponseInit = {}) {
  22. return new Response(JSON.stringify(value), {
  23. ...init,
  24. headers: { 'content-type': 'application/json', ...init.headers },
  25. })
  26. }
  27. describe('system update routes', () => {
  28. it('allows loopback updates and requires an explicit opt-in for LAN clients', () => {
  29. expect(systemUpdateRequestAllowed('127.0.0.1')).toBe(true)
  30. expect(systemUpdateRequestAllowed('::ffff:127.0.0.1')).toBe(true)
  31. expect(systemUpdateRequestAllowed('192.168.1.8')).toBe(false)
  32. expect(systemUpdateRequestAllowed('192.168.1.8', true)).toBe(true)
  33. })
  34. it('checks and queues a fixed Web release without reconciling the plugin', async () => {
  35. const root = mkdtempSync(join(tmpdir(), 'hermes-system-update-routes-'))
  36. roots.push(root)
  37. const projectRoot = join(root, 'project')
  38. mkdirSync(projectRoot)
  39. writeFileSync(join(projectRoot, 'release.json'), JSON.stringify({
  40. schemaVersion: 1, releaseVersion: '0.2.0', webVersion: '0.2.0', gitTag: 'v0.2.0',
  41. }))
  42. const config: ServerConfig = {
  43. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  44. allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: join(root, 'media'),
  45. attachmentsRoot: join(root, 'attachments'), imagesRoot: join(root, 'images'), mediaOwner: 'tester',
  46. allowInsecureLan: false, insecureLan: false, production: false,
  47. releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/hermes-yaoyao.git',
  48. }
  49. const launched: string[] = []
  50. const latest = {
  51. schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0',
  52. }
  53. const updates = new SystemUpdateManager(config, {
  54. projectRoot,
  55. inspectRemote: async () => ({ manifest: latest, commit: 'b'.repeat(40) }),
  56. launchUpdater: path => { launched.push(path) },
  57. platform: 'darwin',
  58. })
  59. let pluginVersion = '1.7.1'
  60. const fetchImpl = (async (input: string | URL | Request) => {
  61. const path = new URL(input instanceof Request ? input.url : String(input)).pathname
  62. if (path === '/api/status') return json({ auth_required: true }, { headers: { 'set-cookie': 'hermes_session_at=session; Path=/; HttpOnly' } })
  63. if (path === '/api/auth/me') return json({ user_id: 'admin', display_name: '管理员' })
  64. if (path === '/api/profiles') return json({ profiles: [{ name: 'default', is_default: true }] })
  65. if (path === '/api/plugins/yaoyao/profiles') return json({ profiles: [] })
  66. if (path === '/api/dashboard/plugins') return json([{ name: 'yaoyao', version: pluginVersion }])
  67. if (path === '/api/plugins/yaoyao/maintenance/storage') return json({ ready: true })
  68. if (path === '/api/dashboard/agent-plugins/install') {
  69. pluginVersion = '1.8.0'
  70. return json({ ok: true, plugin_name: 'yaoyao', enabled: true })
  71. }
  72. return json({ ok: true })
  73. }) as typeof fetch
  74. const runtime = createApplication({ config, fetchImpl, updates })
  75. runtimes.push(runtime)
  76. const bootstrap = await request(runtime.app.callback())
  77. .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
  78. const cookie = cookieHeader(bootstrap)
  79. const mutation = (path: string) => request(runtime.app.callback())
  80. .post(path)
  81. .set('Host', '127.0.0.1:8800')
  82. .set('Origin', 'http://127.0.0.1:8800')
  83. .set('Cookie', cookie)
  84. .set('X-CSRF-Token', bootstrap.body.csrfToken)
  85. const status = await request(runtime.app.callback())
  86. .get('/api/app/system/update/status').set('Host', '127.0.0.1:8800').set('Cookie', cookie).expect(200)
  87. expect(status.body).toMatchObject({ updateAvailable: false })
  88. expect(status.body.installedPluginVersion).toBeUndefined()
  89. const check = await mutation('/api/app/system/update/check').send({}).expect(200)
  90. expect(check.body).toMatchObject({ latest, updateAvailable: true })
  91. const queued = await mutation('/api/app/system/update/apply').send({ targetVersion: '0.3.0' }).expect(202)
  92. expect(queued.body).toMatchObject({ operation: 'update', state: 'queued', target: latest })
  93. expect(launched).toHaveLength(1)
  94. expect(pluginVersion).toBe('1.7.1')
  95. const jobResponse = await request(runtime.app.callback())
  96. .get(`/api/app/system/update/jobs/${queued.body.id}`)
  97. .set('Host', '127.0.0.1:8800').set('Cookie', cookie)
  98. .expect(200)
  99. expect(jobResponse.body).toMatchObject({ id: queued.body.id, state: 'queued' })
  100. })
  101. })