offlineSystemUpdate.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  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:15300'
  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: 15300, 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 = 'offline-test-password') {
  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(boot.body.setupRequired ? '/api/app/setup' : '/api/app/login').set('Host', host).set('Origin', origin)
  42. .set('X-CSRF-Token', boot.body.csrfToken).send({ username, password }).expect(200)
  43. if (!signedIn.body.user.mustChangePassword) {
  44. return { agent, csrf: signedIn.body.csrfToken as string, user: signedIn.body.user }
  45. }
  46. const changed = await agent.put('/api/app/account/credentials').set('Host', host).set('Origin', origin)
  47. .set('X-CSRF-Token', signedIn.body.csrfToken)
  48. .send({ currentPassword: password, newPassword: 'offline-test-password', username }).expect(200)
  49. return { agent, csrf: changed.body.csrfToken as string, user: changed.body.user }
  50. }
  51. describe('independent Web updates', () => {
  52. it.each(['offline', 'unauthorized', 'server-error', 'stalled'] as const)('keeps every update route independent of a %s 9119', async failure => {
  53. const f = fixture()
  54. const { agent, csrf } = await login(f.runtime)
  55. if (failure === 'unauthorized') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 401 }))
  56. if (failure === 'server-error') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 503 }))
  57. if (failure === 'stalled') f.fetchImpl.mockImplementation(() => new Promise<Response>(() => {}))
  58. f.fetchImpl.mockClear()
  59. const mutation = (path: string) => agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', csrf)
  60. const status = await agent.get(base + '/status').set('Host', host).expect(200)
  61. expect(status.body.current.webVersion).toBe('0.2.0')
  62. expect(status.body.installedPluginVersion).toBeUndefined()
  63. const checked = await mutation('/check').send({}).expect(200)
  64. expect(checked.body).toMatchObject({ updateAvailable: true, latest, supported: true })
  65. await mutation('/apply').send({ targetVersion: 'arbitrary-ref' }).expect(400)
  66. await mutation('/apply').send({ targetVersion: '0.2.1' }).expect(409)
  67. expect(f.launchUpdater).not.toHaveBeenCalled()
  68. const queued = await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(202)
  69. expect(queued.body).toMatchObject({ operation: 'update', target: latest, state: 'queued' })
  70. expect(queued.body.plan).toBeUndefined()
  71. const jobPath = join(f.config.home, 'updates', `${queued.body.id}.json`)
  72. const stored = JSON.parse(readFileSync(jobPath, 'utf8'))
  73. expect(stored.plan.targetCommit).toBe('b'.repeat(40))
  74. await agent.get(`${base}/jobs/${queued.body.id}`).set('Host', host).expect(200)
  75. await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(409)
  76. expect(f.launchUpdater).toHaveBeenCalledTimes(1)
  77. writeFileSync(jobPath, JSON.stringify({ ...stored, state: 'succeeded' }))
  78. rmSync(join(f.config.home, 'updates', 'active.lock'))
  79. writeFileSync(join(f.config.home, 'updates', 'last-success.json'), '{}')
  80. const rollback = await mutation('/rollback').send({}).expect(202)
  81. expect(rollback.body).toMatchObject({ operation: 'rollback', state: 'queued' })
  82. await agent.get(`${base}/jobs/${rollback.body.id}`).set('Host', host).expect(200)
  83. expect(f.fetchImpl).not.toHaveBeenCalled()
  84. })
  85. it('still requires local login, admin privilege, Origin and CSRF when 9119 is down', async () => {
  86. const f = fixture()
  87. const anonymous = request.agent(f.runtime.app.callback())
  88. const boot = await anonymous.get('/api/app/bootstrap').set('Host', host).expect(200)
  89. const getPaths = ['/status', '/jobs/11111111-1111-4111-8111-111111111111']
  90. for (const path of getPaths) await anonymous.get(base + path).set('Host', host).expect(401)
  91. for (const path of ['/check', '/apply', '/rollback', '/activate']) {
  92. await anonymous.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
  93. .send({ targetVersion: '0.3.0' }).expect(401)
  94. }
  95. const admin = await login(f.runtime)
  96. f.runtime.auth.create(admin.user, 'member', 'temporary-password')
  97. const member = await login(f.runtime, 'member', 'temporary-password')
  98. f.fetchImpl.mockClear()
  99. for (const path of getPaths) await member.agent.get(base + path).set('Host', host).expect(403)
  100. for (const path of ['/check', '/apply', '/rollback', '/activate']) {
  101. await member.agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', member.csrf)
  102. .send({ targetVersion: '0.3.0' }).expect(403)
  103. await admin.agent.post(base + path).set('Host', host).set('Origin', origin).send({}).expect(403)
  104. await admin.agent.post(base + path).set('Host', host).set('Origin', 'https://wrong.example')
  105. .set('X-CSRF-Token', admin.csrf).send({}).expect(403)
  106. }
  107. expect(f.fetchImpl).not.toHaveBeenCalled()
  108. expect(f.launchUpdater).not.toHaveBeenCalled()
  109. })
  110. it('preserves local login during a stalled bootstrap and still exposes update status', async () => {
  111. const f = fixture()
  112. const { agent } = await login(f.runtime)
  113. f.fetchImpl.mockClear()
  114. let finish!: (response: Response) => void
  115. f.fetchImpl.mockImplementation(() => new Promise<Response>(resolve => { finish = resolve }))
  116. const started = Date.now()
  117. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  118. expect(Date.now() - started).toBeLessThan(8_000)
  119. expect(boot.body).toMatchObject({ authenticated: true, upstreamReady: false, user: { role: 'admin' } })
  120. expect(boot.body.upstreamError).toContain('仍可管理 15300 和升级 Web')
  121. await agent.get(base + '/status').set('Host', host).expect(200)
  122. expect(f.fetchImpl).toHaveBeenCalledTimes(1)
  123. finish(Response.json({}, { status: 503 }))
  124. }, 10_000)
  125. it('reports real release-source failures without launching or contacting 9119', async () => {
  126. const f = fixture()
  127. const { agent, csrf } = await login(f.runtime)
  128. f.fetchImpl.mockClear()
  129. f.inspectRemote.mockRejectedValue(new Error('Git source unreachable'))
  130. const failed = await agent.post(base + '/check').set('Host', host).set('Origin', origin)
  131. .set('X-CSRF-Token', csrf).send({}).expect(502)
  132. expect(failed.body).toMatchObject({ code: 'system_update_source_failed' })
  133. expect(failed.body.error).toContain('Git source unreachable')
  134. expect(f.fetchImpl).not.toHaveBeenCalled()
  135. expect(f.launchUpdater).not.toHaveBeenCalled()
  136. })
  137. })