| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139 |
- // @vitest-environment node
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import request from 'supertest'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
- import type { ServerConfig } from '../../src/server/config.js'
- import { SystemUpdateManager } from '../../src/server/updateManager.js'
- const runtimes: ApplicationRuntime[] = []
- const roots: string[] = []
- const host = '127.0.0.1:8800'
- const origin = `http://${host}`
- const base = '/api/app/system/update'
- const latest = { schemaVersion: 1 as const, releaseVersion: '0.3.0', webVersion: '0.3.0', pluginVersion: '9.0.0', gitTag: 'v0.3.0' }
- afterEach(() => {
- for (const runtime of runtimes.splice(0)) runtime.close()
- for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
- })
- function fixture() {
- const root = mkdtempSync(join(tmpdir(), 'yaoyao-offline-update-')); roots.push(root)
- const projectRoot = join(root, 'project'); mkdirSync(projectRoot)
- writeFileSync(join(projectRoot, 'release.json'), JSON.stringify({ ...latest, releaseVersion: '0.2.0', webVersion: '0.2.0', gitTag: 'v0.2.0' }))
- const config: ServerConfig = {
- host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
- allowedHosts: new Set(), home: join(root, 'data'), mediaRoot: root,
- attachmentsRoot: root, imagesRoot: root, mediaOwner: 'tester',
- allowInsecureLan: false, insecureLan: false, production: false,
- releaseRoot: join(root, 'installed'), releaseSource: 'https://example.test/repo.git',
- }
- const fetchImpl = vi.fn<typeof fetch>(async () => { throw new Error('9119 offline') })
- const inspectRemote = vi.fn(async () => ({ manifest: latest, commit: 'b'.repeat(40) }))
- const launchUpdater = vi.fn(() => undefined)
- const updates = new SystemUpdateManager(config, { projectRoot, inspectRemote, launchUpdater, platform: 'darwin' })
- const runtime = createApplication({ config, fetchImpl, updates }); runtimes.push(runtime)
- return { runtime, fetchImpl, inspectRemote, launchUpdater, config }
- }
- async function login(runtime: ApplicationRuntime, username = 'admin', password = 'admin') {
- const agent = request.agent(runtime.app.callback())
- const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
- const signedIn = await agent.post('/api/app/login').set('Host', host).set('Origin', origin)
- .set('X-CSRF-Token', boot.body.csrfToken).send({ username, password }).expect(200)
- const changed = await agent.put('/api/app/account/credentials').set('Host', host).set('Origin', origin)
- .set('X-CSRF-Token', signedIn.body.csrfToken)
- .send({ currentPassword: password, newPassword: 'offline-test-password', username }).expect(200)
- return { agent, csrf: changed.body.csrfToken as string, user: changed.body.user }
- }
- describe('independent Web updates', () => {
- it.each(['offline', 'unauthorized', 'server-error', 'stalled'] as const)('keeps every update route independent of a %s 9119', async failure => {
- const f = fixture()
- const { agent, csrf } = await login(f.runtime)
- if (failure === 'unauthorized') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 401 }))
- if (failure === 'server-error') f.fetchImpl.mockResolvedValue(Response.json({}, { status: 503 }))
- if (failure === 'stalled') f.fetchImpl.mockImplementation(() => new Promise<Response>(() => {}))
- f.fetchImpl.mockClear()
- const mutation = (path: string) => agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', csrf)
- const status = await agent.get(base + '/status').set('Host', host).expect(200)
- expect(status.body.current.webVersion).toBe('0.2.0')
- expect(status.body.installedPluginVersion).toBeUndefined()
- const checked = await mutation('/check').send({}).expect(200)
- expect(checked.body).toMatchObject({ updateAvailable: true, latest, supported: true })
- await mutation('/apply').send({ targetVersion: 'arbitrary-ref' }).expect(400)
- await mutation('/apply').send({ targetVersion: '0.2.1' }).expect(409)
- expect(f.launchUpdater).not.toHaveBeenCalled()
- const queued = await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(202)
- expect(queued.body).toMatchObject({ operation: 'update', target: latest, state: 'queued' })
- expect(queued.body.plan).toBeUndefined()
- const jobPath = join(f.config.home, 'updates', `${queued.body.id}.json`)
- const stored = JSON.parse(readFileSync(jobPath, 'utf8'))
- expect(stored.plan.targetCommit).toBe('b'.repeat(40))
- await agent.get(`${base}/jobs/${queued.body.id}`).set('Host', host).expect(200)
- await mutation('/apply').send({ targetVersion: '0.3.0' }).expect(409)
- expect(f.launchUpdater).toHaveBeenCalledTimes(1)
- writeFileSync(jobPath, JSON.stringify({ ...stored, state: 'succeeded' }))
- rmSync(join(f.config.home, 'updates', 'active.lock'))
- writeFileSync(join(f.config.home, 'updates', 'last-success.json'), '{}')
- const rollback = await mutation('/rollback').send({}).expect(202)
- expect(rollback.body).toMatchObject({ operation: 'rollback', state: 'queued' })
- await agent.get(`${base}/jobs/${rollback.body.id}`).set('Host', host).expect(200)
- expect(f.fetchImpl).not.toHaveBeenCalled()
- })
- it('still requires local login, admin privilege, Origin and CSRF when 9119 is down', async () => {
- const f = fixture()
- const anonymous = request.agent(f.runtime.app.callback())
- const boot = await anonymous.get('/api/app/bootstrap').set('Host', host).expect(200)
- const getPaths = ['/status', '/jobs/11111111-1111-4111-8111-111111111111']
- for (const path of getPaths) await anonymous.get(base + path).set('Host', host).expect(401)
- for (const path of ['/check', '/apply', '/rollback']) {
- await anonymous.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
- .send({ targetVersion: '0.3.0' }).expect(401)
- }
- const admin = await login(f.runtime)
- f.runtime.auth.create(admin.user, 'member', 'temporary-password')
- const member = await login(f.runtime, 'member', 'temporary-password')
- for (const path of getPaths) await member.agent.get(base + path).set('Host', host).expect(403)
- for (const path of ['/check', '/apply', '/rollback']) {
- await member.agent.post(base + path).set('Host', host).set('Origin', origin).set('X-CSRF-Token', member.csrf)
- .send({ targetVersion: '0.3.0' }).expect(403)
- await admin.agent.post(base + path).set('Host', host).set('Origin', origin).send({}).expect(403)
- await admin.agent.post(base + path).set('Host', host).set('Origin', 'https://wrong.example')
- .set('X-CSRF-Token', admin.csrf).send({}).expect(403)
- }
- expect(f.fetchImpl).not.toHaveBeenCalled()
- expect(f.launchUpdater).not.toHaveBeenCalled()
- })
- it('preserves local login during a stalled bootstrap and still exposes update status', async () => {
- const f = fixture()
- const { agent } = await login(f.runtime)
- let finish!: (response: Response) => void
- f.fetchImpl.mockImplementation(() => new Promise<Response>(resolve => { finish = resolve }))
- const started = Date.now()
- const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
- expect(Date.now() - started).toBeLessThan(8_000)
- expect(boot.body).toMatchObject({ authenticated: true, upstreamReady: false, user: { role: 'admin' } })
- expect(boot.body.upstreamError).toContain('仍可管理 8800 和升级 Web')
- await agent.get(base + '/status').set('Host', host).expect(200)
- expect(f.fetchImpl).toHaveBeenCalledTimes(1)
- finish(Response.json({}, { status: 503 }))
- }, 10_000)
- it('reports real release-source failures without launching or contacting 9119', async () => {
- const f = fixture()
- const { agent, csrf } = await login(f.runtime)
- f.inspectRemote.mockRejectedValue(new Error('Git source unreachable'))
- const failed = await agent.post(base + '/check').set('Host', host).set('Origin', origin)
- .set('X-CSRF-Token', csrf).send({}).expect(502)
- expect(failed.body).toMatchObject({ code: 'system_update_source_failed' })
- expect(failed.body.error).toContain('Git source unreachable')
- expect(f.fetchImpl).not.toHaveBeenCalled()
- expect(f.launchUpdater).not.toHaveBeenCalled()
- })
- })
|