Files
Capsule/tests/releases.test.mjs
2026-09-12 21:45:37 -04:00

122 lines
6.4 KiB
JavaScript

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, UPLOAD_CHUNK_SIZE } 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')))
}
})
test('large artifacts use contiguous bounded chunks and verify the assembled public file', async () => {
const data = Buffer.alloc(UPLOAD_CHUNK_SIZE * 2 + 17, 42)
const requests = []
await uploadRelease({ feedUrl: feed, files: [{ name: 'Capsule-0.1.1-x64-Setup.exe', data }] }, {
uploadUrl: 'https://upload.thetempusproject.com/capsule/upload/', token: 'test-token', log: () => {},
fetchImpl: async (_url, options) => {
if (options.method !== 'PUT') return { ok: true, arrayBuffer: async () => data }
requests.push(options)
return { ok: true, status: requests.length < 3 ? 202 : 201 }
}
})
assert.equal(requests.length, 3)
assert.deepEqual(requests.map((request) => request.headers['Content-Range']), [
`bytes 0-524287/${data.length}`, `bytes 524288-1048575/${data.length}`, `bytes 1048576-1048592/${data.length}`
])
assert.equal(new Set(requests.map((request) => request.headers['X-Capsule-Upload-Id'])).size, 1)
assert.deepEqual(Buffer.concat(requests.map((request) => request.body)), data)
})