add builder and updater
This commit is contained in:
102
tests/releases.test.mjs
Normal file
102
tests/releases.test.mjs
Normal file
@ -0,0 +1,102 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { stringify } from 'yaml'
|
||||
import { createBuildConfig, validateFeedUrl, APP_ID } from '../build/config.mjs'
|
||||
import { digest, writeReleaseManifest, verifyRelease } from '../scripts/releaseArtifacts.mjs'
|
||||
import { uploadRelease } from '../scripts/uploadRelease.mjs'
|
||||
|
||||
const feed = 'https://downloads.thetempusproject.com/capsule/windows/x64/'
|
||||
const env = { CAPSULE_UPDATE_URL: feed, CAPSULE_PUBLISHER_NAME: 'Test Publisher', CSC_NAME: 'Test Publisher' }
|
||||
|
||||
test('release builds require explicit HTTPS hosting and signing identity', () => {
|
||||
assert.throws(() => createBuildConfig({ release: true, env: {} }), /HTTPS/)
|
||||
assert.throws(() => createBuildConfig({ release: true, env: { CAPSULE_UPDATE_URL: feed } }), /PUBLISHER/)
|
||||
assert.throws(() => createBuildConfig({ release: true, env: { ...env, CSC_NAME: '' } }), /CSC_LINK/)
|
||||
for (const url of ['http://host/path/', 'https://u:p@host/path/', 'https://host/path/?token=secret', 'https://host/path/#x', 'https://localhost/', 'https://example.com/', 'https://host/path']) {
|
||||
assert.throws(() => validateFeedUrl(url))
|
||||
}
|
||||
const config = createBuildConfig({ release: true, env })
|
||||
assert.equal(config.appId, APP_ID)
|
||||
assert.equal(config.forceCodeSigning, true)
|
||||
assert.equal(config.win.verifyUpdateCodeSignature, true)
|
||||
assert.equal(config.win.signtoolOptions.certificateSubjectName, env.CSC_NAME)
|
||||
assert.equal(config.publish[0].url, feed)
|
||||
assert.equal(config.extraMetadata.capsuleUpdates.enabled, true)
|
||||
})
|
||||
|
||||
test('local installer has separate identity, no feed, and cannot wipe app data', () => {
|
||||
const config = createBuildConfig({ env: {} })
|
||||
assert.notEqual(config.appId, APP_ID)
|
||||
assert.equal(config.extraMetadata.name, 'capsule-local')
|
||||
assert.equal(config.extraMetadata.capsuleUpdates.enabled, false)
|
||||
assert.equal(config.publish, null)
|
||||
assert.equal(config.nsis.perMachine, false)
|
||||
assert.equal(config.nsis.deleteAppDataOnUninstall, false)
|
||||
})
|
||||
|
||||
test('release verification detects tampering and publishes metadata last', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'capsule-release-test-'))
|
||||
t.after(() => rm(directory, { recursive: true, force: true }))
|
||||
const binary = Buffer.from('synthetic installer fixture')
|
||||
const name = 'Capsule-0.1.1-x64-Setup.exe'
|
||||
const metadata = { version: '0.1.1', files: [{ url: name, sha512: digest(binary, 'sha512', 'base64'), size: binary.length }] }
|
||||
await writeFile(join(directory, name), binary)
|
||||
await writeFile(join(directory, `${name}.blockmap`), 'synthetic blockmap')
|
||||
await writeFile(join(directory, 'latest.yml'), stringify(metadata))
|
||||
await assert.rejects(verifyRelease(directory), /ENOENT/)
|
||||
await writeReleaseManifest(directory, feed)
|
||||
const verified = await verifyRelease(directory)
|
||||
assert.deepEqual(verified.files.map((file) => file.name), [name, `${name}.blockmap`, 'latest.yml'])
|
||||
await writeFile(join(directory, `${name}.blockmap`), 'tampered')
|
||||
await assert.rejects(verifyRelease(directory), /changed/)
|
||||
await writeFile(join(directory, name), 'wrong installer')
|
||||
await assert.rejects(verifyRelease(directory), /checksum/)
|
||||
metadata.files[0].url = '../../outside.exe'
|
||||
await writeFile(join(directory, 'latest.yml'), stringify(metadata))
|
||||
await assert.rejects(verifyRelease(directory), /filename/)
|
||||
})
|
||||
|
||||
test('upload verifies public assets before announcing release and keeps credentials private', async () => {
|
||||
const files = ['Capsule-0.1.1-x64-Setup.exe', 'Capsule-0.1.1-x64-Setup.exe.blockmap', 'latest.yml']
|
||||
.map((name) => ({ name, data: Buffer.from(name) }))
|
||||
const calls = []
|
||||
await uploadRelease({ feedUrl: feed, files }, {
|
||||
uploadUrl: 'https://upload.thetempusproject.com/capsule/', token: 'test-token', log: () => {},
|
||||
fetchImpl: async (url, options) => {
|
||||
const name = url.pathname.split('/').pop()
|
||||
calls.push(`${options.method || 'GET'} ${name}`)
|
||||
assert.equal(options.redirect, 'error')
|
||||
if (options.method === 'PUT') {
|
||||
assert.equal(options.headers.Authorization, 'Bearer test-token')
|
||||
assert.equal(options.headers['If-None-Match'], name === 'latest.yml' ? undefined : '*')
|
||||
return { ok: true, status: 201 }
|
||||
}
|
||||
assert.equal(options.headers, undefined)
|
||||
return { ok: true, arrayBuffer: async () => files.find((file) => file.name === name).data }
|
||||
}
|
||||
})
|
||||
assert.deepEqual(calls, files.flatMap(({ name }) => [`PUT ${name}`, `GET ${name}`]))
|
||||
})
|
||||
|
||||
test('upload stops before metadata on failed upload or mismatched public artifact', async () => {
|
||||
const release = { feedUrl: feed, files: [
|
||||
{ name: 'Capsule-0.1.1-x64-Setup.exe', data: Buffer.from('expected') },
|
||||
{ name: 'latest.yml', data: Buffer.from('metadata') }
|
||||
] }
|
||||
for (const failUpload of [true, false]) {
|
||||
const calls = []
|
||||
await assert.rejects(uploadRelease(release, {
|
||||
uploadUrl: 'https://upload.thetempusproject.com/capsule/', token: 'test-token', log: () => {},
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(url.pathname)
|
||||
return options.method === 'PUT'
|
||||
? { ok: !failUpload, status: failUpload ? 500 : 201 }
|
||||
: { ok: true, arrayBuffer: async () => Buffer.from('wrong') }
|
||||
}
|
||||
}), /failed/)
|
||||
assert.ok(calls.every((path) => !path.endsWith('latest.yml')))
|
||||
}
|
||||
})
|
||||
132
tests/updates.test.mjs
Normal file
132
tests/updates.test.mjs
Normal file
@ -0,0 +1,132 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createUpdateController } from '../src/main/updateController.mjs'
|
||||
import { registerUpdateIpc, isTrustedUpdateSender } from '../src/main/updateIpc.mjs'
|
||||
|
||||
function fixture(enabled = true) {
|
||||
const updater = new EventEmitter()
|
||||
const states = []
|
||||
const scheduled = []
|
||||
const cancelled = []
|
||||
updater.checks = 0
|
||||
updater.installs = []
|
||||
updater.checkForUpdates = async () => {
|
||||
updater.checks++
|
||||
updater.emit('update-not-available', { version: '0.1.0' })
|
||||
return {}
|
||||
}
|
||||
updater.quitAndInstall = (...args) => updater.installs.push(args)
|
||||
const controller = createUpdateController({
|
||||
updater, enabled, version: '0.1.0', onState: (state) => states.push(state),
|
||||
schedule: (callback, delay) => { const task = { callback, delay }; scheduled.push(task); return task },
|
||||
cancel: (task) => cancelled.push(task)
|
||||
})
|
||||
return { updater, controller, states, scheduled, cancelled }
|
||||
}
|
||||
|
||||
test('local/dev builds never check, schedule, or install', async () => {
|
||||
const { controller, updater, scheduled } = fixture(false)
|
||||
controller.start()
|
||||
await controller.check()
|
||||
assert.equal(controller.snapshot().status, 'disabled')
|
||||
assert.equal(updater.checks, 0)
|
||||
assert.equal(scheduled.length, 0)
|
||||
assert.equal(controller.install(), false)
|
||||
})
|
||||
|
||||
test('release checks after launch and periodically, with cleanup', async () => {
|
||||
const { controller, updater, scheduled, cancelled } = fixture()
|
||||
controller.start()
|
||||
controller.start()
|
||||
assert.equal(scheduled.length, 1)
|
||||
assert.equal(scheduled[0].delay, 15000)
|
||||
await scheduled[0].callback()
|
||||
assert.equal(updater.checks, 1)
|
||||
assert.equal(controller.snapshot().status, 'current')
|
||||
assert.equal(scheduled[1].delay, 14400000)
|
||||
controller.dispose()
|
||||
assert.equal(cancelled[0], scheduled[1])
|
||||
assert.equal(updater.listenerCount('download-progress'), 0)
|
||||
})
|
||||
|
||||
test('network and download failures are retryable without exposing raw errors', async () => {
|
||||
const { controller, updater } = fixture()
|
||||
updater.checkForUpdates = async () => { throw new Error('private endpoint or local path') }
|
||||
await controller.check()
|
||||
assert.equal(controller.snapshot().status, 'error')
|
||||
assert.doesNotMatch(JSON.stringify(controller.snapshot()), /private/)
|
||||
updater.checkForUpdates = async () => ({ downloadPromise: Promise.reject(new Error('checksum mismatch')) })
|
||||
await controller.check()
|
||||
assert.equal(controller.snapshot().status, 'error')
|
||||
updater.checkForUpdates = async () => {
|
||||
updater.emit('update-not-available')
|
||||
return {}
|
||||
}
|
||||
await controller.check()
|
||||
assert.equal(controller.snapshot().status, 'current')
|
||||
})
|
||||
|
||||
test('one in-flight download, progress, verified readiness, and explicit restart', async () => {
|
||||
const { controller, updater } = fixture()
|
||||
let finish
|
||||
updater.checkForUpdates = async () => {
|
||||
updater.checks++
|
||||
updater.emit('update-available', { version: '0.1.1' })
|
||||
return { downloadPromise: new Promise((resolve) => { finish = resolve }) }
|
||||
}
|
||||
assert.equal(controller.install(), false)
|
||||
const pending = controller.check()
|
||||
await controller.check()
|
||||
assert.equal(updater.checks, 1)
|
||||
updater.emit('download-progress', { percent: 53.2 })
|
||||
assert.equal(controller.snapshot().percent, 53)
|
||||
assert.equal(controller.install(), false)
|
||||
updater.emit('update-downloaded', { version: '0.1.1' })
|
||||
finish()
|
||||
await pending
|
||||
assert.equal(updater.autoInstallOnAppQuit, false)
|
||||
assert.equal(updater.allowDowngrade, false)
|
||||
assert.equal(updater.allowPrerelease, false)
|
||||
assert.equal(updater.installs.length, 0)
|
||||
await controller.check()
|
||||
assert.equal(updater.checks, 1)
|
||||
assert.equal(controller.install(), true)
|
||||
assert.equal(controller.install(), false)
|
||||
assert.deepEqual(updater.installs, [[true, true]])
|
||||
})
|
||||
|
||||
test('updater error event clears readiness and prevents installation', () => {
|
||||
const { controller, updater } = fixture()
|
||||
updater.emit('update-downloaded', { version: '0.1.1' })
|
||||
updater.emit('error', new Error('verification failed'))
|
||||
assert.equal(controller.install(), false)
|
||||
assert.equal(controller.snapshot().status, 'error')
|
||||
})
|
||||
|
||||
test('update IPC rejects sites, child frames, and unowned windows', () => {
|
||||
const url = 'file:///C:/Capsule/out/renderer/index.html'
|
||||
const frame = { url: `${url}#/settings` }
|
||||
const sender = { mainFrame: frame }
|
||||
const windows = [{ webContents: sender, isDestroyed: () => false }]
|
||||
const event = { sender, senderFrame: frame }
|
||||
assert.equal(isTrustedUpdateSender(event, windows, url), true)
|
||||
assert.equal(isTrustedUpdateSender({ ...event, senderFrame: { url } }, windows, url), false)
|
||||
assert.equal(isTrustedUpdateSender(event, [], url), false)
|
||||
frame.url = 'https://ttp.joeykimsey.com/'
|
||||
assert.equal(isTrustedUpdateSender(event, windows, url), false)
|
||||
frame.url = `${url}?redirect=other`
|
||||
assert.equal(isTrustedUpdateSender(event, windows, url), false)
|
||||
|
||||
const handlers = new Map()
|
||||
const { controller } = fixture()
|
||||
const cleanup = registerUpdateIpc({
|
||||
ipcMain: { handle: (name, handler) => handlers.set(name, handler), removeHandler: (name) => handlers.delete(name) },
|
||||
controller, getWindows: () => windows, rendererUrl: url
|
||||
})
|
||||
assert.throws(() => handlers.get('capsule:updates:install')(event), /denied/)
|
||||
frame.url = url
|
||||
assert.equal(handlers.get('capsule:updates:status')(event).currentVersion, '0.1.0')
|
||||
cleanup()
|
||||
assert.equal(handlers.size, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user