194 lines
13 KiB
TypeScript
194 lines
13 KiB
TypeScript
// Mac App Store build of a Tauri app (cli/lib/mas.ts, cli/apple/build-mas.ts): plan, entitlements, identities, path-free
|
|
// flags, leak scan, app / pkg checks, and the pkg variant of the build upload requests.
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { tauriCommand } from '../cli/apple/build-mas';
|
|
import { appProblems, categoryId, entitlementKeysOf, entitlementsXml, findTauri, masEntitlements, masSteps, parsePackageInfo, parsePkgSignature, pathFreeEnv, pickMasIdentities, pickMasProfiles, pkgProblems, scanPathLeaks, tauriOverlay } from '../cli/lib/mas';
|
|
import { altoolArgs, buildUploadReqs } from '../cli/lib/xcode';
|
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'sb-mas-'));
|
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
const conf = { productName: 'Demo App', identifier: 'com.example.demo', version: '1.2.3', bundle: { category: 'Utility', macOS: { minimumSystemVersion: '12.0' } } };
|
|
function fixture(): string {
|
|
const root = mkdtempSync(join(tmp, 'p-'));
|
|
mkdirSync(join(root, 'apps/desktop/src-tauri'), { recursive: true });
|
|
writeFileSync(join(root, 'apps/desktop/src-tauri/tauri.conf.json'), JSON.stringify(conf));
|
|
return root;
|
|
}
|
|
|
|
describe('tauri project', () => {
|
|
test('found by scan and by hint; fields read', () => {
|
|
const root = fixture();
|
|
const t = findTauri(root);
|
|
expect(t.productName).toBe('Demo App');
|
|
expect(t.identifier).toBe('com.example.demo');
|
|
expect(t.cwd).toBe(join(root, 'apps/desktop'));
|
|
expect(t.minOs).toBe('12.0');
|
|
expect(findTauri(root, 'apps/desktop/src-tauri').dir).toBe(t.dir);
|
|
expect(() => findTauri(root, 'nope')).toThrow('no tauri.conf.json');
|
|
});
|
|
test('none or several found', () => {
|
|
const empty = mkdtempSync(join(tmp, 'e-'));
|
|
expect(() => findTauri(empty)).toThrow('no tauri.conf.json found');
|
|
const two = fixture();
|
|
mkdirSync(join(two, 'other/src-tauri'), { recursive: true });
|
|
writeFileSync(join(two, 'other/src-tauri/tauri.conf.json'), JSON.stringify(conf));
|
|
expect(() => findTauri(two)).toThrow('several Tauri projects');
|
|
});
|
|
test('tauri command: project binary, cargo-tauri, else bunx', () => {
|
|
expect(tauriCommand('/a/b', p => /a.node_modules..bin.tauri$/.test(p), () => null)).toHaveLength(1);
|
|
expect(tauriCommand('/a/b', () => false, c => (c === 'cargo-tauri' ? '/x' : null))).toEqual(['cargo', 'tauri']);
|
|
expect(tauriCommand('/a/b', () => false, () => null)[0]).toBe('bunx');
|
|
});
|
|
test('category ids', () => {
|
|
expect(categoryId('Utility')).toBe('public.app-category.utilities');
|
|
expect(categoryId('public.app-category.games')).toBe('public.app-category.games');
|
|
expect(categoryId('Nope')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('entitlements', () => {
|
|
test('sandbox and identifiers forced, project keys kept', () => {
|
|
const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo', existing: { 'com.apple.security.app-sandbox': false, 'com.apple.security.files.user-selected.read-write': true } });
|
|
expect(e['com.apple.security.app-sandbox']).toBe(true);
|
|
expect(e['com.apple.application-identifier']).toBe('ABCDE12345.com.example.demo');
|
|
expect(e['com.apple.developer.team-identifier']).toBe('ABCDE12345');
|
|
expect(e['com.apple.security.files.user-selected.read-write']).toBe(true);
|
|
expect(e['com.apple.security.network.client']).toBe(true);
|
|
});
|
|
test('xml round trip', () => {
|
|
const e = masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' });
|
|
expect(entitlementKeysOf(entitlementsXml(e))?.['com.apple.security.app-sandbox']).toBe(true);
|
|
expect(entitlementKeysOf('garbage')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('identities and profiles', () => {
|
|
const ids = [
|
|
{ sha1: 'A', name: 'Apple Development: X (AAAAAAAAAA)' },
|
|
{ sha1: 'B', name: 'Apple Distribution: Co (ABCDE12345)' },
|
|
{ sha1: 'C', name: '3rd Party Mac Developer Installer: Co (ABCDE12345)' },
|
|
{ sha1: 'D', name: 'Apple Distribution: Other (ZZZZZZZZZZ)' },
|
|
];
|
|
test('app and installer identity of the team', () => {
|
|
const p = pickMasIdentities(ids, 'ABCDE12345');
|
|
expect(p.app?.sha1).toBe('B');
|
|
expect(p.installer?.sha1).toBe('C');
|
|
expect(pickMasIdentities(ids, 'QQQQQQQQQQ')).toEqual({ app: null, installer: null });
|
|
expect(pickMasIdentities([{ sha1: 'E', name: '3rd Party Mac Developer Application: Co (ABCDE12345)' }, ids[1]!], 'ABCDE12345').app?.sha1).toBe('E');
|
|
expect(pickMasIdentities([{ sha1: 'F', name: 'Mac Installer Distribution: Co (ABCDE12345)' }, ids[2]!], 'ABCDE12345').installer?.sha1).toBe('F');
|
|
});
|
|
const prof = (o: Record<string, unknown>) => ({ path: '/p', profile: { UUID: 'u1', Name: 'n', TeamIdentifier: ['ABCDE12345'], Platform: ['OSX'], ExpirationDate: '2099-01-01T00:00:00Z',
|
|
Entitlements: { 'com.apple.application-identifier': 'ABCDE12345.com.example.demo' }, ...o } as never });
|
|
test('Mac App Store profile: macOS, no devices, matching id, not expired', () => {
|
|
expect(pickMasProfiles([prof({})], 'com.example.demo', 'ABCDE12345')).toHaveLength(1);
|
|
expect(pickMasProfiles([prof({ Platform: ['iOS'] })], 'com.example.demo', null)).toHaveLength(0);
|
|
expect(pickMasProfiles([prof({ ProvisionedDevices: ['x'] })], 'com.example.demo', null)).toHaveLength(0);
|
|
expect(pickMasProfiles([prof({})], 'com.example.other', null)).toHaveLength(0);
|
|
expect(pickMasProfiles([prof({ ExpirationDate: '2000-01-01T00:00:00Z' })], 'com.example.demo', null)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('path-free', () => {
|
|
test('remaps checkout, cargo, rustup, home; no debug info', () => {
|
|
const e = pathFreeEnv({ home: '/Users/dev', user: 'dev', root: '/Users/dev/work/app', rustflags: '-C target-cpu=native' });
|
|
expect(e.RUSTFLAGS).toContain('-C target-cpu=native');
|
|
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/work/app=/src');
|
|
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.cargo=/cargo');
|
|
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev/.rustup=/rustup');
|
|
expect(e.RUSTFLAGS).toContain('--remap-path-prefix=/Users/dev=/home/build');
|
|
expect(e.CARGO_PROFILE_RELEASE_DEBUG).toBe('0');
|
|
|
|
});
|
|
test('leak scan finds the home folder in a binary and a plist', () => {
|
|
const app = mkdtempSync(join(tmp, 'app-'));
|
|
mkdirSync(join(app, 'Contents/MacOS'), { recursive: true });
|
|
writeFileSync(join(app, 'Contents/MacOS/bin'), Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from('panic at /Users/dev/.cargo/registry/x.rs'), Buffer.from([0])]));
|
|
writeFileSync(join(app, 'Contents/Info.plist'), 'clean');
|
|
const leaks = scanPathLeaks(app, ['/Users/dev', '/Users/dev/']);
|
|
expect(leaks.map(l => `${l.file}:${l.needle}`)).toEqual(['Contents/MacOS/bin:/Users/dev', 'Contents/MacOS/bin:/Users/dev/']);
|
|
expect(scanPathLeaks(app, ['/Users/nobody'])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('app and pkg checks', () => {
|
|
const good = { info: { CFBundleIdentifier: 'com.example.demo', CFBundleShortVersionString: '1.2.3', CFBundleVersion: '7', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false },
|
|
entitlements: masEntitlements({ teamId: 'ABCDE12345', bundleId: 'com.example.demo' }), hasProfile: true, leaks: [] };
|
|
const o = { bundleId: 'com.example.demo', version: '1.2.3', build: '7', signed: true, pathFree: true };
|
|
test('a good app passes', () => { expect(appProblems(good, o)).toEqual({ errors: [], warnings: [] }); });
|
|
test('missing sandbox, category, min OS, profile, wrong build', () => {
|
|
const bad = { ...good, info: { ...good.info, CFBundleVersion: '6', LSApplicationCategoryType: undefined, LSMinimumSystemVersion: undefined } as never, entitlements: { 'com.apple.security.app-sandbox': false }, hasProfile: false };
|
|
const e = appProblems(bad, o).errors.join('\n');
|
|
for (const s of ['CFBundleVersion is 6', 'LSApplicationCategoryType', 'LSMinimumSystemVersion', 'app-sandbox is not true', 'application-identifier', 'embedded.provisionprofile']) expect(e).toContain(s);
|
|
expect(appProblems({ ...good, entitlements: null }, o).errors[0]).toContain('no entitlements');
|
|
});
|
|
test('get-task-allow refused; unsigned skips entitlement checks', () => {
|
|
expect(appProblems({ ...good, entitlements: { ...good.entitlements!, 'com.apple.security.get-task-allow': true } }, o).errors.join()).toContain('get-task-allow');
|
|
expect(appProblems({ ...good, entitlements: null, hasProfile: false }, { ...o, signed: false }).errors).toEqual([]);
|
|
});
|
|
test('path leaks are errors with --path-free, warnings without', () => {
|
|
const leaks = [{ file: 'Contents/MacOS/bin', needle: '/Users/dev', count: 3 }];
|
|
expect(appProblems({ ...good, leaks }, o).errors[0]).toContain('local paths');
|
|
const w = appProblems({ ...good, leaks }, { ...o, pathFree: false });
|
|
expect(w.errors).toEqual([]);
|
|
expect(w.warnings.join()).toContain('--path-free');
|
|
});
|
|
test('pkgutil --check-signature', () => {
|
|
const ok = parsePkgSignature('Package "a.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Co (ABCDE12345)\n Expires: 2027');
|
|
expect(ok).toMatchObject({ signed: true, installerCert: true, certificate: '3rd Party Mac Developer Installer: Co (ABCDE12345)' });
|
|
expect(pkgProblems(ok)).toEqual([]);
|
|
expect(pkgProblems(parsePkgSignature('Package "a.pkg":\n Status: no signature'))[0]).toContain('not signed');
|
|
const dev = parsePkgSignature(' Status: signed by a certificate trusted by macOS\n 1. Developer ID Installer: Co (ABCDE12345)');
|
|
expect(pkgProblems(dev)[0]).toContain('not a "3rd Party Mac Developer Installer"');
|
|
});
|
|
test('PackageInfo', () => {
|
|
const x = '<pkg-info identifier="com.example.demo" version="1.2.3"><bundle path="./Demo.app" id="com.example.demo" CFBundleShortVersionString="1.2.3" CFBundleVersion="7"/></pkg-info>';
|
|
expect(parsePackageInfo(x)).toEqual({ bundleId: 'com.example.demo', version: '1.2.3', build: '7' });
|
|
expect(parsePackageInfo('<pkg-info/>')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('plan', () => {
|
|
const t = findTauri(fixture());
|
|
const base = { tauri: t, tauriCmd: ['cargo', 'tauri'], target: 'universal-apple-darwin', out: '/o', configFile: '/o/c.json', entitlementsFile: '/o/e', profile: '/o/p', appIdentity: 'Apple Distribution: Co (ABCDE12345)',
|
|
installerIdentity: '3rd Party Mac Developer Installer: Co (ABCDE12345)', version: '1.2.3', build: '7', unsigned: false, pathFree: true, pkg: '/o/Demo App.pkg' };
|
|
test('signed steps: tauri app bundle, productbuild with the installer identity', () => {
|
|
const s = masSteps(base);
|
|
expect(s.tauri).toEqual(['cargo', 'tauri', 'build', '--bundles', 'app', '--target', 'universal-apple-darwin', '--config', '/o/c.json']);
|
|
expect(s.productbuild.slice(0, 2)).toEqual(['productbuild', '--component']);
|
|
expect(s.productbuild).toContain('--sign');
|
|
expect(s.productbuild.at(-1)).toBe('/o/Demo App.pkg');
|
|
expect(s.pkgCheck).toEqual(['pkgutil', '--check-signature', '/o/Demo App.pkg']);
|
|
});
|
|
test('unsigned: no --sign', () => { expect(masSteps({ ...base, unsigned: true }).productbuild).not.toContain('--sign'); });
|
|
test('tauri overlay: identity, entitlements, embedded profile, bundleVersion', () => {
|
|
const o = tauriOverlay({ ...base, minOs: '12.0' }) as { version: string; bundle: { macOS: Record<string, unknown> } };
|
|
expect(o.version).toBe('1.2.3');
|
|
expect(o.bundle.macOS.bundleVersion).toBe('7');
|
|
expect(o.bundle.macOS.signingIdentity).toBe(base.appIdentity);
|
|
expect(o.bundle.macOS.hardenedRuntime).toBe(true);
|
|
expect(o.bundle.macOS.files).toEqual({ 'embedded.provisionprofile': '/o/p' });
|
|
const u = tauriOverlay({ ...base, unsigned: true }) as { bundle: { macOS: Record<string, unknown> } };
|
|
expect(u.bundle.macOS.signingIdentity).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('build upload of a pkg', () => {
|
|
const info = { bundleId: 'com.example.demo', version: '1.2.3', build: '7' };
|
|
test('platform MAC_OS and the pkg uti; iOS unchanged', () => {
|
|
const m = buildUploadReqs('app1', info, 'Demo.pkg', 10, 'MAC_OS');
|
|
expect((m.upload.body.data.attributes as Record<string, unknown>).platform).toBe('MAC_OS');
|
|
expect((m.file('u1').body.data.attributes as Record<string, unknown>).uti).toBe('com.apple.pkg');
|
|
const i = buildUploadReqs('app1', info, 'Demo.ipa', 10);
|
|
expect((i.upload.body.data.attributes as Record<string, unknown>).platform).toBe('IOS');
|
|
expect((i.file('u1').body.data.attributes as Record<string, unknown>).uti).toBe('com.apple.ipa');
|
|
});
|
|
test('altool gets --type macos for a pkg', () => {
|
|
const k = { keyPath: 'k', keyId: 'i', issuerId: 's' };
|
|
expect(altoolArgs('/o/Demo.pkg', k)).toContain('macos');
|
|
expect(altoolArgs('/o/Demo.ipa', k)).not.toContain('--type');
|
|
});
|
|
});
|