offlineSystemUpdate.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. // @vitest-environment node
  2. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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, vi } from 'vitest'
  7. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. import { SystemUpdateManager } from '../../src/server/updateManager.js'
  10. const runtimes: ApplicationRuntime[] = []
  11. const roots: string[] = []
  12. const host = '127.0.0.1:8800'
  13. const origin = `http://${host}`
  14. const base = '/api/app/system/update'
  15. const latest = { schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', gitTag: 'v0.3.0' }
  16. afterEach(() => {
  17. for (const runtime of runtimes.splice(0)) runtime.close()
  18. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  19. })
  20. function fixture() {
  21. const root = mkdtempSync(join(tmpdir(), 'yaoyao-offline-update-')); roots.push(root)
  22. const projectRoot = join(root, 'project'); mkdirSync(projectRoot)
  23. writeFileSync(join(projectRoot, 'release.json'), JSON.stringify({ ...latest, releaseVersion: '0.2.0', webVersion: '0.2.0', gitTag: 'v0.2.0' }))
  24. const config: ServerConfig = {
  25. host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
  26. allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: root,
  27. attachmentsRoot: root, imagesRoot: root, mediaOwner: 'tester',
  28. allowInsecureLan: false, insecureLan: false, production: false,
  29. releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/repo.git',
  30. }
  31. const fetchImpl = vi.fn<typeof fetch>(async () => { throw new Error('9119 offline') })
  32. const inspectRemote = vi.fn(async () => ({ manifest: latest, commit: 'b'.repeat(40) }))
  33. const launchUpdater = vi.fn(() => undefined)
  34. const updates = new SystemUpdateManager(config, { projectRoot, inspectRemote, launchUpdater, platform: 'darwin' })
  35. const runtime = createApplication({ config, fetchImpl, updates }); runtimes.push(runtime)
  36. return { runtime, fetchImpl, inspectRemote, launchUpdater, config }
  37. }
  38. async function login(runtime: ApplicationRuntime, username = 'admin', password = 'admin') {
  39. const agent = request.agent(runtime.app.callback())
  40. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  41. const signedIn = await agent.post('/api/app/login').set('Host', host).set('Origin', origin)
  42. .set('X-CSRF-Token', boot.body.csrfToken).send({ username, password }).expect(200)
  43. const changed = await agent.put('/api/app/account/credentials').set('Host', host).set('Origin', origin)
  44. .set('X-CSRF-Token', signedIn.body.csrfToken)
  45. .send({ currentPassword: password, newPassword: 'offline-test-password', username }).expect(200)
  46. return { agent, csrf: changed.body.csrfToken as string, user: changed.body.user }
  47. }
  48. describe('independent Web updates', () => {
  49. it.each(['offline', 'unauthorized', 'server-error', 'stalled'] as const)('keeps every update route independent of a %s 9119', async failure => {
  50. const f = fixture()
  51. const { agent, csrf } = await login(f.runtime)
  52. if (failure === 'unauthorized') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 401 }))
  53. if (failure === 'server-error') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 503 }))
  54. if (failure === 'stalled') f.fetchImpl.mockImplementation(() => new Promise<Response>(() => {}))
  55. f.fetchImpl.mockClear()
  56. const mutation = (path: string) => agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', csrf)
  57. const status = await agent.get(base + '/status').set('Host', host).expect(200)
  58. expect(status.body.current.webVersion).toBe('0.2.0')
  59. expect(status.body.installedPluginVersion).toBeUndefined()
  60. const checked = await mutation('/check').send({}).expect(200)
  61. expect(checked.body).toMatchObject({ updateAvailable: true, latest, supported: true })
  62. await mutation('/apply').send({ targetVersion: 'arbitrary-ref' }).expect(400)
  63. await mutation('/apply').send({ targetVersion: '0.2.1' }).expect(409)
  64. expect(f.launchUpdater).not.toHaveBeenCalled()
  65. const queued = await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(202)
  66. expect(queued.body).toMatchObject({ operation: 'update', target: latest, state: 'queued' })
  67. expect(queued.body.plan).toBeUndefined()
  68. const jobPath = join(f.config.home, 'updates', `${queued.body.id}.json`)
  69. const stored = JSON.parse(readFileSync(jobPath, 'utf8'))
  70. expect(stored.plan.targetCommit).toBe('b'.repeat(40))
  71. await agent.get(`${base}/jobs/${queued.body.id}`).set('Host', host).expect(200)
  72. await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(409)
  73. expect(f.launchUpdater).toHaveBeenCalledTimes(1)
  74. writeFileSync(jobPath, JSON.stringify({ ...stored, state: 'succeeded' }))
  75. rmSync(join(f.config.home, 'updates', 'active.lock'))
  76. writeFileSync(join(f.config.home, 'updates', 'last-success.json'), '{}')
  77. const rollback = await mutation('/rollback').send({}).expect(202)
  78. expect(rollback.body).toMatchObject({ operation: 'rollback', state: 'queued' })
  79. await agent.get(`${base}/jobs/${rollback.body.id}`).set('Host', host).expect(200)
  80. expect(f.fetchImpl).not.toHaveBeenCalled()
  81. })
  82. it('still requires local login, admin privilege, Origin and CSRF when 9119 is down', async () => {
  83. const f = fixture()
  84. const anonymous = request.agent(f.runtime.app.callback())
  85. const boot = await anonymous.get('/api/app/bootstrap').set('Host', host).expect(200)
  86. const getPaths = ['/status', '/jobs/11111111-1111-4111-8111-111111111111']
  87. for (const path of getPaths) await anonymous.get(base + path).set('Host', host).expect(401)
  88. for (const path of ['/check', '/apply', '/rollback']) {
  89. await anonymous.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
  90. .send({ targetVersion: '0.3.0' }).expect(401)
  91. }
  92. const admin = await login(f.runtime)
  93. f.runtime.auth.create(admin.user, 'member', 'temporary-password')
  94. const member = await login(f.runtime, 'member', 'temporary-password')
  95. for (const path of getPaths) await member.agent.get(base + path).set('Host', host).expect(403)
  96. for (const path of ['/check', '/apply', '/rollback']) {
  97. await member.agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', member.csrf)
  98. .send({ targetVersion: '0.3.0' }).expect(403)
  99. await admin.agent.post(base + path).set('Host', host).set('Origin', origin).send({}).expect(403)
  100. await admin.agent.post(base + path).set('Host', host).set('Origin', 'https://wrong.example')
  101. .set('X-CSRF-Token', admin.csrf).send({}).expect(403)
  102. }
  103. expect(f.fetchImpl).not.toHaveBeenCalled()
  104. expect(f.launchUpdater).not.toHaveBeenCalled()
  105. })
  106. it('preserves local login during a stalled bootstrap and still exposes update status', async () => {
  107. const f = fixture()
  108. const { agent } = await login(f.runtime)
  109. let finish!: (response: Response) => void
  110. f.fetchImpl.mockImplementation(() => new Promise<Response>(resolve => { finish = resolve }))
  111. const started = Date.now()
  112. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  113. expect(Date.now() - started).toBeLessThan(8_000)
  114. expect(boot.body).toMatchObject({ authenticated: true, upstreamReady: false, user: { role: 'admin' } })
  115. expect(boot.body.upstreamError).toContain('仍可管理 8800 和升级 Web')
  116. await agent.get(base + '/status').set('Host', host).expect(200)
  117. expect(f.fetchImpl).toHaveBeenCalledTimes(1)
  118. finish(Response.json({}, { status: 503 }))
  119. }, 10_000)
  120. it('reports real release-source failures without launching or contacting 9119', async () => {
  121. const f = fixture()
  122. const { agent, csrf } = await login(f.runtime)
  123. f.inspectRemote.mockRejectedValue(new Error('Git source unreachable'))
  124. const failed = await agent.post(base + '/check').set('Host', host).set('Origin', origin)
  125. .set('X-CSRF-Token', csrf).send({}).expect(502)
  126. expect(failed.body).toMatchObject({ code: 'system_update_source_failed' })
  127. expect(failed.body.error).toContain('Git source unreachable')
  128. expect(f.fetchImpl).not.toHaveBeenCalled()
  129. expect(f.launchUpdater).not.toHaveBeenCalled()
  130. })
  131. })