154 lines
10 KiB
JavaScript
154 lines
10 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { createServer } from 'node:http'
|
|
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterEach, expect, test } from 'vitest'
|
|
import { stageBundleInputs } from '../tests/install/e2e-assets/bundle-inputs.mjs'
|
|
import { validateBundleInputs, validateDownloadedBundle } from '../tests/install/e2e-assets/bundle-manifest.cjs'
|
|
import { validateBundledManifest } from '../tests/install/e2e-assets/windows-bundled-helpers.mjs'
|
|
import { bundledMatrix, renderMarkdownResults } from '../scripts/sandbox/generate-e2e-matrix.mjs'
|
|
|
|
const cleanup = []
|
|
afterEach(async () => { while (cleanup.length) await cleanup.pop()() })
|
|
function fixture(platform = 'windows') {
|
|
const extension = platform === 'windows' ? 'msixbundle' : 'zip'
|
|
return { schema: 1, platform, arch: 'x64', old: {
|
|
tag: 'v1.2.0', version: platform === 'windows' ? '1.2.0.0' : '1.2.0',
|
|
commit: 'a'.repeat(40), identity: 'test.bundle', teamId: 'ABCDEFGHIJ', publisher: 'CN=Test', applicationId: 'Test',
|
|
artifact: { url: `https://example.com/old.${extension}`, sha256: 'a'.repeat(64) }
|
|
}, new: {
|
|
tag: 'v1.3.0', version: platform === 'windows' ? '1.3.0.0' : '1.3.0',
|
|
commit: 'b'.repeat(40), identity: 'test.bundle', teamId: 'ABCDEFGHIJ', publisher: 'CN=Test', applicationId: 'Test',
|
|
artifact: { url: `https://example.com/new.${extension}`, sha256: 'b'.repeat(64) }
|
|
} }
|
|
}
|
|
|
|
test('package transitions are ordered, identity-preserving, and report through the existing family', () => {
|
|
for (const platform of ['windows', 'macos']) {
|
|
const manifest = fixture(platform)
|
|
expect(validateBundleInputs(manifest, platform, 'x64')).toBe(manifest)
|
|
const arm = { ...manifest, arch: 'arm64' }
|
|
expect(validateBundleInputs(arm, platform, 'arm64')).toBe(arm)
|
|
const { include } = bundledMatrix(platform, manifest.old.tag)
|
|
expect(include).toHaveLength(1)
|
|
expect(include[0].update_method).toBe('open-app-update')
|
|
expect(renderMarkdownResults([{ name: `${include[0].name} / e2e`, conclusion: 'failure' }])).toContain('1 failed')
|
|
}
|
|
})
|
|
|
|
// Independent fault inputs apply to remote admission and resumed phases alike.
|
|
test.each(['windows', 'macos'])('%s rejects malformed and incompatible pairs', platform => {
|
|
const cases = [
|
|
['schema', 2, /schema/], ['platform', 'linux', /platform/], ['arch', 'riscv', /architecture/],
|
|
['arch', 'arm64', /architecture/],
|
|
['new', null, /tag/], ['new.tag', 'main', /tag/], ['new.commit', 'not-a-commit', /commit/],
|
|
['new.identity', 4, /identity/], ['new.identity', 'other.bundle', /identity/],
|
|
['new.commit', 'a'.repeat(40), /commit/], ['new.artifact', null, /SHA-256/],
|
|
['new.artifact.sha256', ['b'.repeat(64)], /SHA-256/],
|
|
['new.artifact.sha256', 'A'.repeat(64), /SHA-256/], ['new.artifact.sha256', 'a'.repeat(64), /differ/],
|
|
['new.artifact.url', '', /url/], ['new.artifact.url', 'http://example.com/new.zip', /HTTPS/],
|
|
['new.artifact.url', 'https://user:pass@example.com/new.zip', /credentials/],
|
|
['new.artifact.url', 'https://example.com/new.exe', /artifact/],
|
|
...(platform === 'windows' ? [
|
|
['new.version', '1.2.0.0', /increase/], ['new.version', '1.1.9.9', /increase/],
|
|
['new.version', '1.3.0', /four numeric/], ['new.version', '1.3.65536.0', /16 bits/],
|
|
['new.publisher', '', /publisher/], ['new.publisher', 'CN=Other', /publisher/],
|
|
['new.applicationId', 7, /applicationId/], ['new.applicationId', 'Other', /applicationId/],
|
|
] : [
|
|
['new.identity', 'TEAM123456', /CFBundleIdentifier/], ['new.version', '1.9.0', /match/],
|
|
['new.teamId', ['ABCDEFGHIJ'], /teamId/], ['new.teamId', '', /teamId/], ['new.teamId', 'OTHER12345', /signing team/],
|
|
]),
|
|
]
|
|
const oldCases = cases.filter(([field]) => field.startsWith('new') && field !== 'new.version')
|
|
.map(([field, value, diagnostic]) => [field.replace(/^new/, 'old'),
|
|
field === 'new.commit' && value === 'a'.repeat(40) ? 'b'.repeat(40) :
|
|
field === 'new.artifact.sha256' && value === 'a'.repeat(64) ? 'b'.repeat(64) : value, diagnostic])
|
|
oldCases.push(...(platform === 'windows' ? [
|
|
['old.version', '1.3.0.0', /increase/], ['old.version', '1.4.0.0', /increase/],
|
|
['old.version', '1.2.0', /four numeric/], ['old.version', '1.2.65536.0', /16 bits/],
|
|
] : [['old.version', '1.9.0', /match/]]))
|
|
for (const [field, value, diagnostic] of [...cases, ...oldCases]) {
|
|
const bad = fixture(platform), keys = field.split('.')
|
|
const last = keys.pop()
|
|
keys.reduce((object, key) => object[key], bad)[last] = value
|
|
expect(() => validateBundleInputs(bad, platform, 'x64'), field).toThrow(diagnostic)
|
|
expect(() => validateDownloadedBundle(bad, platform, 'x64'), field).toThrow(diagnostic)
|
|
}
|
|
expect(() => validateBundleInputs(fixture(platform), platform, 'riscv')).toThrow('Unsupported')
|
|
expect(() => validateBundleInputs(fixture(platform), platform, 'arm64')).toThrow('architecture')
|
|
const pair = fixture(platform)
|
|
if (platform === 'macos') {
|
|
pair.new.tag = 'v1.3.0+canary.20260907T000000Z'; pair.new.version = pair.new.tag.slice(1)
|
|
expect(() => validateBundleInputs(pair, platform, 'x64')).toThrow('channel')
|
|
pair.new.tag = pair.old.tag; pair.new.version = pair.old.version
|
|
expect(() => validateBundleInputs(pair, platform, 'x64')).toThrow('increase')
|
|
} else {
|
|
for (const [old, newer] of [['1.2.3.9', '1.2.3.10'], ['1.2.65535.0', '1.3.0.0']]) {
|
|
pair.old.version = old; pair.new.version = newer
|
|
expect(validateBundleInputs(pair, platform, 'x64')).toBe(pair)
|
|
}
|
|
}
|
|
})
|
|
|
|
test.each(['windows', 'macos'])('%s staging and resumed validation use actual files and reject tampering', async platform => {
|
|
// These bytes test download integrity only, not native package acceptance.
|
|
const bytes = { old: Buffer.from('old transport payload'), new: Buffer.from('new transport payload') }
|
|
const manifest = fixture(platform)
|
|
const server = createServer((req, res) => {
|
|
res.end(req.url === '/manifest.json' ? JSON.stringify(manifest) : bytes[req.url.slice(1).split('.')[0]])
|
|
})
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
cleanup.push(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve) }))
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'bundle-input-test-'))
|
|
cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
|
const base = `http://127.0.0.1:${server.address().port}`
|
|
for (const slot of ['old', 'new']) {
|
|
manifest[slot].artifact.url = `${base}/${slot}.${platform === 'windows' ? 'msixbundle' : 'zip'}`
|
|
manifest[slot].artifact.sha256 = createHash('sha256').update(bytes[slot]).digest('hex')
|
|
}
|
|
const out = path.join(directory, 'good')
|
|
const manifestSha256 = createHash('sha256').update(JSON.stringify(manifest)).digest('hex')
|
|
await expect(stageBundleInputs({ manifestUrl: `${base}/manifest.json`, platform, arch: 'x64', out, manifestSha256: '0'.repeat(64) })).rejects.toThrow('manifest SHA-256')
|
|
await expect(stageBundleInputs({ manifestUrl: `${base}/manifest.json`, platform, arch: 'x64', out, expectedCommit: 'c'.repeat(40) })).rejects.toThrow('workflow SHA')
|
|
const filename = await stageBundleInputs({ manifestUrl: `${base}/manifest.json`, platform, arch: 'x64', out, manifestSha256 })
|
|
const result = JSON.parse(await readFile(filename, 'utf8'))
|
|
expect(validateDownloadedBundle(result, platform, 'x64')).toBe(result)
|
|
if (platform === 'windows') {
|
|
expect(validateBundledManifest(result, { expectedPublisher: 'CN=Test' })).toEqual({ ok: true, errors: [] })
|
|
expect(validateBundledManifest(result, { expectedPublisher: 'CN=Other' }).errors.join()).toContain('OUT_OF_STORE_PUBLISHER')
|
|
}
|
|
// Native Mac resume uses require(), not an ESM-only import.
|
|
const validator = fileURLToPath(new URL('../tests/install/e2e-assets/bundle-manifest.cjs', import.meta.url))
|
|
execFileSync(process.execPath, ['-e', 'require(process.argv[1]).validateDownloadedBundle(require(process.argv[2]), process.argv[3], "x64")', validator, filename, platform])
|
|
for (const slot of ['old', 'new']) {
|
|
for (const missing of [undefined, path.join(directory, 'missing'), directory]) {
|
|
const bad = structuredClone(result); bad[slot].artifact.path = missing
|
|
expect(() => validateDownloadedBundle(bad, platform, 'x64')).toThrow('artifact.path')
|
|
if (platform === 'windows') expect(validateBundledManifest(bad, { expectedPublisher: 'CN=Test' }).ok).toBe(false)
|
|
}
|
|
}
|
|
const altered = structuredClone(result); altered.new.commit = altered.old.commit
|
|
expect(() => validateDownloadedBundle(altered, platform, 'x64')).toThrow('commit')
|
|
if (platform === 'windows') expect(validateBundledManifest(altered, { expectedPublisher: 'CN=Test' }).ok).toBe(false)
|
|
for (const slot of ['old', 'new']) expect(await readFile(result[slot].artifact.path)).toEqual(bytes[slot])
|
|
manifest.old.artifact.sha256 = 'c'.repeat(64)
|
|
const bad = path.join(directory, 'bad')
|
|
await expect(stageBundleInputs({ manifestUrl: `${base}/manifest.json`, platform, arch: 'x64', out: bad })).rejects.toThrow('SHA-256')
|
|
expect(await readdir(bad)).toEqual([])
|
|
})
|
|
|
|
test('explicit bundled routes refuse absent package inputs instead of reporting a skipped pass', async () => {
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'bundle-plan-test-'))
|
|
cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
|
const script = fileURLToPath(new URL('../tests/install/e2e-assets/bundle-plan.mjs', import.meta.url))
|
|
const env = { ...process.env, BUNDLE_WINDOWS_MANIFEST: '', BUNDLE_MACOS_MANIFEST: '',
|
|
GITHUB_OUTPUT: path.join(directory, 'output'), GITHUB_STEP_SUMMARY: path.join(directory, 'summary') }
|
|
expect(() => execFileSync(process.execPath, [script], { env: { ...env, BUNDLE_ROUTE: 'windows-bundled' }, stdio: 'pipe' })).toThrow()
|
|
execFileSync(process.execPath, [script], { env: { ...env, BUNDLE_ROUTE: 'all' }, stdio: 'pipe' })
|
|
expect(await readFile(env.GITHUB_STEP_SUMMARY, 'utf8')).toContain('no signed package pair supplied')
|
|
expect(await readFile(env.GITHUB_OUTPUT, 'utf8')).toContain('windows={"include":[]}')
|
|
})
|