190 lines
5.7 KiB
JavaScript
190 lines
5.7 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import {execFileSync} from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import test from 'node:test'
|
|
|
|
const validator = path.resolve(import.meta.dirname, 'validate-release.mjs')
|
|
const sha = 'a'.repeat(40)
|
|
const runtime = 'b'.repeat(40)
|
|
|
|
function fixture(channel = 'production') {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-release-'))
|
|
fs.mkdirSync(path.join(directory, 'bundle'))
|
|
fs.writeFileSync(
|
|
path.join(directory, 'report.json'),
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
platform: 'ios',
|
|
nativeProfile: 'production',
|
|
sourceCommit: sha,
|
|
runtimeVersion: runtime,
|
|
fingerprintPolicyVersion: 1,
|
|
fingerprintToolVersion: '0.20.8',
|
|
toolVersions: {
|
|
expo: '57.0.8',
|
|
expoUpdates: '57.0.10',
|
|
node: 'v24.19.0',
|
|
packageManager: 'pnpm@11.21.0',
|
|
},
|
|
fingerprintSources: [
|
|
{type: 'contents', id: 'expoConfig', hash: 'c'.repeat(40)},
|
|
{
|
|
type: 'file',
|
|
filePath: 'scripts/ota/fingerprint-policy.json',
|
|
hash: 'd'.repeat(40),
|
|
},
|
|
...['BlueskyClip', 'BlueskyNSE', 'Share-with-Bluesky'].map(name => ({
|
|
type: 'dir',
|
|
filePath: `modules/${name}`,
|
|
hash: 'e'.repeat(40),
|
|
})),
|
|
],
|
|
}),
|
|
)
|
|
const release = {
|
|
schemaVersion: 1,
|
|
sourceCommit: sha,
|
|
channel,
|
|
nativeProfile: 'production',
|
|
bundleVersion: '1788537600000',
|
|
platforms: {
|
|
ios: {
|
|
runtimeVersion: runtime,
|
|
fingerprintReportRef: 'report.json',
|
|
bundleDirectory: 'bundle',
|
|
...(channel === 'production' ? {targetNativeBuildNumber: '42'} : {}),
|
|
},
|
|
},
|
|
}
|
|
const releaseFile = path.join(directory, 'ota-export.json')
|
|
fs.writeFileSync(releaseFile, JSON.stringify(release))
|
|
return {directory, release, releaseFile}
|
|
}
|
|
|
|
function validate(releaseFile) {
|
|
return execFileSync(
|
|
process.execPath,
|
|
[validator, '--release-file', releaseFile],
|
|
{
|
|
encoding: 'utf8',
|
|
},
|
|
)
|
|
}
|
|
|
|
test('production requires an exact build but permits an unavailable receipt', () => {
|
|
const value = fixture()
|
|
const result = JSON.parse(validate(value.releaseFile))
|
|
assert.deepEqual(result.platforms.ios.receiptVerification, {
|
|
status: 'unverified',
|
|
reason: 'receipt-unavailable',
|
|
})
|
|
delete value.release.platforms.ios.targetNativeBuildNumber
|
|
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
|
assert.throws(
|
|
() => validate(value.releaseFile),
|
|
/Production ios target is required/,
|
|
)
|
|
})
|
|
|
|
test('testflight rejects build targeting', () => {
|
|
const value = fixture('testflight')
|
|
value.release.nativeProfile = 'testflight'
|
|
value.release.platforms.ios.targetNativeBuildNumber = '42'
|
|
const report = JSON.parse(
|
|
fs.readFileSync(path.join(value.directory, 'report.json')),
|
|
)
|
|
report.nativeProfile = 'testflight'
|
|
fs.writeFileSync(
|
|
path.join(value.directory, 'report.json'),
|
|
JSON.stringify(report),
|
|
)
|
|
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
|
assert.throws(
|
|
() => validate(value.releaseFile),
|
|
/must not target a native build/,
|
|
)
|
|
})
|
|
|
|
test('a supplied receipt with an invalid source SHA fails closed', () => {
|
|
const value = fixture()
|
|
fs.mkdirSync(path.join(value.directory, 'receipts'))
|
|
value.release.platforms.ios.targetNativeBuildReceiptRef =
|
|
'receipts/receipt.json'
|
|
fs.writeFileSync(
|
|
path.join(value.directory, 'receipts/receipt.json'),
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
platform: 'ios',
|
|
nativeProfile: 'production',
|
|
defaultChannel: 'production',
|
|
appVersion: '1.133.0',
|
|
nativeBuildNumber: '42',
|
|
runtimeVersion: runtime,
|
|
sourceCommit: 'short',
|
|
fingerprintPolicyVersion: 1,
|
|
fingerprintToolVersion: '0.20.8',
|
|
artifactDigest: 'c'.repeat(64),
|
|
buildRunUrl: 'https://github.example/build/1',
|
|
fingerprintReportRef: 'report.json',
|
|
}),
|
|
)
|
|
fs.writeFileSync(path.join(value.directory, 'receipts/report.json'), '{}')
|
|
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
|
assert.throws(() => validate(value.releaseFile), /receipt is incompatible/)
|
|
})
|
|
|
|
test('bundle directories cannot escape the release directory', () => {
|
|
const value = fixture('testflight')
|
|
value.release.nativeProfile = 'testflight'
|
|
value.release.platforms.ios.bundleDirectory = '../bundle'
|
|
const report = JSON.parse(
|
|
fs.readFileSync(path.join(value.directory, 'report.json')),
|
|
)
|
|
report.nativeProfile = 'testflight'
|
|
fs.writeFileSync(
|
|
path.join(value.directory, 'report.json'),
|
|
JSON.stringify(report),
|
|
)
|
|
fs.writeFileSync(value.releaseFile, JSON.stringify(value.release))
|
|
assert.throws(
|
|
() => validate(value.releaseFile),
|
|
/escapes the release directory/,
|
|
)
|
|
})
|
|
|
|
test('channel and native profile must agree', () => {
|
|
const value = fixture('testflight')
|
|
assert.throws(
|
|
() => validate(value.releaseFile),
|
|
/requires the testflight native profile/,
|
|
)
|
|
})
|
|
|
|
test('incomplete fingerprint reports are rejected', () => {
|
|
const value = fixture()
|
|
const report = JSON.parse(
|
|
fs.readFileSync(path.join(value.directory, 'report.json')),
|
|
)
|
|
delete report.fingerprintToolVersion
|
|
fs.writeFileSync(
|
|
path.join(value.directory, 'report.json'),
|
|
JSON.stringify(report),
|
|
)
|
|
assert.throws(
|
|
() => validate(value.releaseFile),
|
|
/fingerprint report is incomplete or inconsistent/,
|
|
)
|
|
})
|
|
|
|
test('symlinked bundle directories cannot escape the release directory', () => {
|
|
const value = fixture()
|
|
fs.rmSync(path.join(value.directory, 'bundle'), {recursive: true})
|
|
fs.symlinkSync(os.tmpdir(), path.join(value.directory, 'bundle'))
|
|
assert.throws(
|
|
() => validate(value.releaseFile),
|
|
/escapes the release directory/,
|
|
)
|
|
})
|