import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { appleTeamId, buildIos, bundleIdentifier, selectIdentity, validateProfile, } from '../build-ios.mjs'; const certificate = Buffer.from('synthetic test certificate'); const fingerprint = createHash('sha1') .update(certificate) .digest('hex') .toUpperCase(); function profileFor(mode = 'testflight') { return { UUID: '00000000-1111-2222-3333-444444444444', TeamIdentifier: [appleTeamId], Platform: ['iOS'], ExpirationDate: '2099-01-01T00:00:00Z', DeveloperCertificates: [certificate.toString('base64')], ...(mode === 'development' ? { ProvisionedDevices: ['test-device'] } : {}), Entitlements: { 'application-identifier': `${appleTeamId}.${bundleIdentifier}`, 'com.apple.developer.team-identifier': appleTeamId, 'get-task-allow': mode === 'development', 'beta-reports-active': mode === 'testflight', }, }; } function fixture(t, mode = 'testflight', overrides = {}) { const root = mkdtempSync(join(tmpdir(), 'kanbangit-ios-test-')); t.after(() => rmSync(root, { recursive: true, force: true })); const directory = join(root, 'repo/native'); const profilePath = join(root, 'profile.mobileprovision'); const config = { productName: 'KanbanGit', identifier: 'de.kanbangit.desktop', version: '1.2.3', }; const ios = { identifier: bundleIdentifier, bundle: { iOS: { developmentTeam: appleTeamId, bundleVersion: '7', minimumSystemVersion: '17.0', }, }, }; mkdirSync(join(directory, 'src-tauri'), { recursive: true }); writeFileSync( join(directory, 'src-tauri/tauri.conf.json'), JSON.stringify(config), ); writeFileSync( join(directory, 'src-tauri/tauri.ios.conf.json'), JSON.stringify(ios), ); writeFileSync(profilePath, 'external provisioning profile'); const profile = profileFor(mode); const calls = []; const destination = join(directory, `src-tauri/target/ios-${mode}/1.2.3-7`); const home = join(root, 'home'); const installedProfile = join( home, `Library/Developer/Xcode/UserData/Provisioning Profiles/${profile.UUID}.mobileprovision`, ); const info = { CFBundleIdentifier: bundleIdentifier, CFBundleShortVersionString: '1.2.3', CFBundleVersion: '7', }; function app(path) { mkdirSync(path, { recursive: true }); writeFileSync( join(path, 'Info.plist'), JSON.stringify({ ...info, ...overrides.info }), ); } const options = { directory, home, mode, platform: 'darwin', log() {}, env: { PATH: '/test/bin', IOS_PROVISIONING_PROFILE: profilePath }, run(command, args, settings) { calls.push({ command, args, settings }); if (overrides.failStep === settings.step) { throw new Error('Simulated build failure'); } if (command === 'python3') { return settings.input.toString(); } if (command === 'security') { if (args[0] === 'cms') { const embedded = args .at(-1) .endsWith('embedded.mobileprovision'); return JSON.stringify( embedded && overrides.embeddedProfile ? overrides.embeddedProfile : profile, ); } return `1) ${fingerprint} "Apple ${mode === 'development' ? 'Development' : 'Distribution'}: Example (${appleTeamId})"`; } if (command === 'rustup') { if (args[0] === 'target') { return ( overrides.targets ?? 'aarch64-apple-ios\naarch64-apple-ios-sim' ); } return `/permanent/toolchain/bin/${args[1]}`; } if (command === '/permanent/toolchain/bin/cargo') { const library = join( directory, 'src-tauri/target', args[args.indexOf('--target') + 1], args.includes('--release') ? 'release' : 'debug', 'libkanbangit_native.a', ); mkdirSync(dirname(library), { recursive: true }); writeFileSync(library, 'compiled library'); } if (command === 'xcodebuild' && args.includes('archive')) { assert.ok(mode === 'simulator' || existsSync(installedProfile)); app( join( args[args.indexOf('-archivePath') + 1], 'Products/Applications/KanbanGit.app', ), ); } if (command === 'xcodebuild' && args.includes('-exportArchive')) { const output = args[args.indexOf('-exportPath') + 1]; mkdirSync(output, { recursive: true }); if (!overrides.missingIpa) { writeFileSync( join(output, 'KanbanGit.ipa'), 'signed ipa bytes', ); } } if (command === 'ditto') { app(join(args.at(-1), 'Payload/KanbanGit.app')); } if (command === 'xcrun') { return '/permanent/Xcode/SDK'; } return ''; }, }; return { options, calls, profile, profilePath, destination, installedProfile, ios, }; } test('TestFlight produces a verified local IPA with manual signing and no Apple mutation or upload flags', (t) => { const f = fixture(t); f.options.env.APPLE_API_KEY = 'must-not-reach-build'; f.options.env.IOS_CERTIFICATE = 'must-not-reach-build'; assert.equal(buildIos(f.options), f.destination); const options = JSON.parse( readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'), ); assert.equal(options.method, 'app-store-connect'); assert.equal(options.destination, 'export'); assert.equal(options.signingStyle, 'manual'); assert.equal(options.manageAppVersionAndBuildNumber, false); assert.equal(options.signingCertificate, fingerprint); assert.deepEqual(options.provisioningProfiles, { [bundleIdentifier]: f.profile.UUID, }); assert.equal( readFileSync( join(f.destination, 'export/KanbanGit.ipa.sha256'), 'utf8', ), `${createHash('sha256').update('signed ipa bytes').digest('hex')} KanbanGit.ipa\n`, ); assert.equal( f.calls.filter( (call) => call.command === 'codesign' && call.args.includes('--verify'), ).length, 2, ); assert.equal(existsSync(f.installedProfile), false); const verification = f.calls.find((call) => call.command === 'codesign'); const requirement = verification.args[verification.args.indexOf('-R') + 1]; assert.ok(requirement.includes('! entitlement["get-task-allow"] exists')); assert.ok( requirement.includes('entitlement["beta-reports-active"] exists'), ); assert.ok(requirement.includes(`certificate leaf = H"${fingerprint}"`)); for (const call of f.calls) { assert.equal(call.settings.env.APPLE_API_KEY, undefined); assert.equal(call.settings.env.IOS_CERTIFICATE, undefined); assert.ok( !call.args.some((arg) => /allowProvisioning|authenticationKey|upload|downloadPlatform/.test( arg, ), ), ); } const archive = f.calls.find((call) => call.args.includes('archive')); assert.ok(archive.args.includes('CODE_SIGN_STYLE=Manual')); assert.ok( archive.args.includes( `PROVISIONING_PROFILE_SPECIFIER=${f.profile.UUID}`, ), ); assert.ok( archive.settings.env.KANBANGIT_IOS_LIBRARY.endsWith( '/aarch64-apple-ios/release/libkanbangit_native.a', ), ); }); test('the simulator needs no certificate or profile and disables signing explicitly', (t) => { const f = fixture(t, 'simulator'); delete f.options.env.IOS_PROVISIONING_PROFILE; buildIos(f.options); assert.ok( f.calls.some((call) => call.args.includes('CODE_SIGNING_ALLOWED=NO')), ); assert.ok( f.calls.some((call) => call.args.includes('aarch64-apple-ios-sim')), ); assert.ok( !f.calls.some( (call) => ['security', 'codesign'].includes(call.command) || call.args.includes('-exportArchive'), ), ); }); test('local device development uses a registered-device development profile', (t) => { const f = fixture(t, 'development'); buildIos(f.options); const options = JSON.parse( readFileSync(join(f.destination, 'ExportOptions.plist'), 'utf8'), ); assert.equal(options.method, 'debugging'); assert.equal(options.destination, 'export'); }); for (const [name, change, message] of [ [ 'different team', (p) => { p.TeamIdentifier = ['OLDTEAM']; }, /explicitly match/, ], [ 'wildcard identifier', (p) => { p.Entitlements['application-identifier'] = `${appleTeamId}.*`; }, /explicitly match/, ], [ 'macOS identifier', (p) => { p.Entitlements['application-identifier'] = `${appleTeamId}.de.kanbangit.desktop`; }, /explicitly match/, ], [ 'expired profile', (p) => { p.ExpirationDate = '2000-01-01'; }, /expired/, ], [ 'invalid expiry', (p) => { p.ExpirationDate = 'invalid'; }, /expired/, ], [ 'invalid UUID', (p) => { p.UUID = '../unexpected'; }, /invalid/, ], [ 'missing certificate', (p) => { p.DeveloperCertificates = []; }, /invalid/, ], [ 'wrong platform', (p) => { p.Platform = ['OSX']; }, /iOS profile/, ], [ 'Ad Hoc profile', (p) => { p.ProvisionedDevices = ['device']; }, /App Store Connect/, ], [ 'enterprise profile', (p) => { p.ProvisionsAllDevices = true; }, /App Store Connect/, ], [ 'development entitlement', (p) => { p.Entitlements['get-task-allow'] = true; }, /App Store Connect/, ], [ 'missing beta entitlement', (p) => { delete p.Entitlements['beta-reports-active']; }, /App Store Connect/, ], ]) { test(`rejects ${name} before signing`, () => { const profile = profileFor(); change(profile); assert.throws(() => validateProfile(profile, 'testflight'), message); }); } test('a distribution profile cannot be used for device development', () => { assert.throws( () => validateProfile(profileFor(), 'development'), /App Development/, ); }); test('identity selection requires the profile certificate and a usable private key of the correct kind', () => { const profile = profileFor(); for (const identities of [ '0 valid identities found', `1) ${'A'.repeat(40)} "Apple Distribution: Other"`, `1) ${fingerprint} "Developer ID Application: Example"`, `1) ${fingerprint} "Apple Development: Example"`, ]) { assert.throws( () => selectIdentity(profile, identities, 'testflight'), /private key/, ); } }); for (const step of [ 'Generating the Xcode project', 'Building the iOS Rust library', 'Archiving locally without Apple provisioning updates', 'Verifying the Apple signature and team', 'Exporting the signed IPA locally (no upload)', ]) { test(`failure at ${step} does not publish a successful build and cleans its profile`, (t) => { const f = fixture(t, 'testflight', { failStep: step }); assert.throws(() => buildIos(f.options), /Simulated build failure/); assert.equal(existsSync(f.destination), false); assert.equal(existsSync(f.installedProfile), false); }); } test('an existing identical local profile is preserved after the build', (t) => { const f = fixture(t); mkdirSync(dirname(f.installedProfile), { recursive: true }); writeFileSync(f.installedProfile, readFileSync(f.profilePath)); buildIos(f.options); assert.equal(existsSync(f.installedProfile), true); }); test('a conflicting local profile is never overwritten or deleted', (t) => { const f = fixture(t); mkdirSync(dirname(f.installedProfile), { recursive: true }); writeFileSync(f.installedProfile, 'user profile'); assert.throws(() => buildIos(f.options), /same UUID/); assert.equal(readFileSync(f.installedProfile, 'utf8'), 'user profile'); }); test('missing toolchain targets fail before compilation without installing anything', (t) => { const f = fixture(t, 'testflight', { targets: 'aarch64-apple-darwin' }); assert.throws( () => buildIos(f.options), /rustup target add aarch64-apple-ios/, ); assert.ok( !f.calls.some( (call) => call.args.includes('build') || call.args.includes('add'), ), ); }); test('missing profile and ambient team overrides fail clearly', (t) => { const f = fixture(t); delete f.options.env.IOS_PROVISIONING_PROFILE; assert.throws(() => buildIos(f.options), /IOS_PROVISIONING_PROFILE/); f.options.env.APPLE_DEVELOPMENT_TEAM = 'OLDTEAM'; assert.throws(() => buildIos(f.options), /must be ZZX2KZGTHU/); delete f.options.env.APPLE_DEVELOPMENT_TEAM; f.options.env.TAURI_CONFIG = '{}'; assert.throws(() => buildIos(f.options), /Remove TAURI_CONFIG/); }); for (const [name, overrides, message] of [ [ 'wrong app version', { info: { CFBundleVersion: '8' } }, /unexpected identifier, version/, ], [ 'changed embedded profile', { embeddedProfile: { ...profileFor(), UUID: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', }, }, /unexpected provisioning profile/, ], ['missing IPA', { missingIpa: true }, /did not produce/], ]) { test(`rejects ${name} in the build output`, (t) => { const f = fixture(t, 'testflight', overrides); assert.throws(() => buildIos(f.options), message); assert.equal(existsSync(f.destination), false); }); } test('completed artifacts are not silently overwritten', (t) => { const f = fixture(t); buildIos(f.options); assert.throws(() => buildIos(f.options), /already exists/); }); for (const mode of ['simulator', 'development']) { test(`${mode} can rebuild the same version and preserves the previous build on failure`, (t) => { const overrides = {}; const f = fixture(t, mode, overrides); buildIos(f.options); const previousBuild = join(f.destination, 'previous-build'); writeFileSync(previousBuild, 'verified artifact'); overrides.failStep = 'Building the iOS Rust library'; assert.throws(() => buildIos(f.options), /Simulated build failure/); assert.equal(readFileSync(previousBuild, 'utf8'), 'verified artifact'); delete overrides.failStep; buildIos(f.options); assert.equal(existsSync(previousBuild), false); assert.ok(existsSync(join(f.destination, 'KanbanGit.xcarchive'))); assert.equal(existsSync(f.installedProfile), false); }); } test('every iOS icon has its declared dimensions and contains no alpha channel or transparency chunk', () => { const assets = fileURLToPath( new URL( '../src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/', import.meta.url, ), ); const catalog = JSON.parse( readFileSync(join(assets, 'Contents.json'), 'utf8'), ); assert.ok( catalog.images.some( (icon) => icon.idiom === 'ios-marketing' && icon.size === '1024x1024', ), ); for (const icon of catalog.images) { const png = readFileSync(join(assets, icon.filename)); assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); const [width, height] = icon.size.split('x').map(Number); const scale = Number.parseInt(icon.scale, 10); assert.equal(png.readUInt32BE(16), width * scale, icon.filename); assert.equal(png.readUInt32BE(20), height * scale, icon.filename); assert.equal(png[25], 2, `${icon.filename} must use RGB without alpha`); for ( let offset = 8; offset < png.length; offset += png.readUInt32BE(offset) + 12 ) { assert.notEqual( png.subarray(offset + 4, offset + 8).toString(), 'tRNS', icon.filename, ); } } });