|
|
@@ -0,0 +1,139 @@
|
|
|
+// @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()
|
|
|
+ })
|
|
|
+})
|