// Signable targets of an Xcode project (app + share extension + App Group entitlements): `apple signing ensure`
// registers the extension's bundle id, enables the capabilities its entitlements need, creates its profile and checks
// the App Group against the profile (the API cannot assign a group to a bundle id); `apple build` over SSH names the
// keychain fixes; `storebound api` writes say how an agent confirms. Fixtures are placeholders (com.example.*).
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { toXmlPlist } from '../cli/lib/plist';
import { run as signing, discoverTargets, profileEntitlements } from '../cli/apple/signing';
import { decide, NOT_A_TTY } from '../cli/api/gate';
import { USAGE } from '../cli/api';
import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain';
import { planProfile, validateSigning } from '../cli/lib/signing';
import { appGroupGaps, appGroupGapsFor, appGroupSteps, bundleIdPortalUrl, pbxTargets, targetKind } from '../cli/lib/targets';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
const APP_ENT = `
aps-environmentdevelopment
com.apple.security.application-groupsgroup.com.example.notes
`;
const SHARE_ENT = `
com.apple.security.application-groupsgroup.com.example.notes
keychain-access-groups$(AppIdentifierPrefix)com.example.notes
com.apple.developer.associated-domainsapplinks:example.com
`;
/** A pbxproj the way Xcode 15 writes it: app, share extension, a unit test bundle, a framework, one variable bundle id. */
const PBX = `// !$*UTF8*$!
{
archiveVersion = 1;
objects = {
/* Begin PBXNativeTarget section */
AAAAAAAAAAAAAAAAAAAAAA01 /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC01 /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = ( );
name = App;
productName = App;
productType = "com.apple.product-type.application";
};
AAAAAAAAAAAAAAAAAAAAAA02 /* ShareExt */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC02 /* Build configuration list for PBXNativeTarget "ShareExt" */;
name = ShareExt;
productType = "com.apple.product-type.app-extension";
};
AAAAAAAAAAAAAAAAAAAAAA03 /* AppTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */;
name = AppTests;
productType = "com.apple.product-type.bundle.unit-test";
};
AAAAAAAAAAAAAAAAAAAAAA04 /* Kit */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */;
name = Kit;
productType = "com.apple.product-type.framework";
};
AAAAAAAAAAAAAAAAAAAAAA05 /* Widget */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC05 /* x */;
name = Widget;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin XCBuildConfiguration section */
BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.notes.debug; };
name = Debug;
};
BBBBBBBBBBBBBBBBBBBBBB02 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"{braces}",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.notes;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
BBBBBBBBBBBBBBBBBBBBBB03 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Share/Share.entitlements";
PRODUCT_BUNDLE_IDENTIFIER = "com.example.notes.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
BBBBBBBBBBBBBBBBBBBBBB04 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(MY_BASE_ID).widget"; };
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CCCCCCCCCCCCCCCCCCCCCC01 /* x */ = {
isa = XCConfigurationList;
buildConfigurations = (
BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */,
BBBBBBBBBBBBBBBBBBBBBB02 /* Release */,
);
};
CCCCCCCCCCCCCCCCCCCCCC02 /* x */ = {
isa = XCConfigurationList;
buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB03 /* Release */, );
};
CCCCCCCCCCCCCCCCCCCCCC03 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( ); };
CCCCCCCCCCCCCCCCCCCCCC05 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB04 ); };
/* End XCConfigurationList section */
};
}
`;
const files = { 'ios/App/App.xcodeproj/project.pbxproj': PBX, 'ios/App/App/App.entitlements': APP_ENT, 'ios/App/Share/Share.entitlements': SHARE_ENT };
describe('pbxproj targets', () => {
test('targetKind: only signable product types', () => {
expect(targetKind('com.apple.product-type.application')).toBe('app');
expect(targetKind('com.apple.product-type.app-extension')).toBe('extension');
expect(targetKind('com.apple.product-type.app-extension.messages-sticker-pack')).toBe('extension');
expect(targetKind('com.apple.product-type.application.watchapp2')).toBe('watch');
expect(targetKind('com.apple.product-type.application.on-demand-install-capable')).toBe('clip');
expect(targetKind('com.apple.product-type.framework')).toBeNull();
expect(targetKind('com.apple.product-type.bundle.unit-test')).toBeNull();
});
test('app + share extension + widget: bundle ids resolved from Release, entitlements paths, tests and frameworks skipped', () => {
const t = pbxTargets(PBX);
expect(t.map(x => x.name)).toEqual(['App', 'ShareExt', 'Widget']);
expect(t[0]).toMatchObject({ kind: 'app', bundleId: 'com.example.notes', entitlements: 'App/App.entitlements' });
expect(t[1]).toMatchObject({ kind: 'extension', bundleId: 'com.example.notes.ShareExt', entitlements: 'Share/Share.entitlements' });
expect(t[2]).toMatchObject({ bundleId: null, unresolved: '$(MY_BASE_ID).widget' });
});
test('discoverTargets reads the entitlements: capabilities and app groups per target; unresolved ids are noted', () => {
const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files);
const s = discoverTargets(p.root, p.loaded.config);
expect(s.main).toMatchObject({ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] });
expect(s.main!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'PUSH_NOTIFICATIONS']);
expect(s.extensions).toHaveLength(1);
expect(s.extensions[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', kind: 'extension', source: 'xcode', appGroups: ['group.com.example.notes'] });
expect(s.extensions[0]!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); // keychain-access-groups has no capability
expect(s.notes.join('\n')).toContain('Widget has bundle id "$(MY_BASE_ID).widget"');
});
test('apple.signing.extensions replaces the detected extensions; extraBundleIds are added', () => {
const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', signing: { extensions: [{ bundleId: 'com.example.notes.widget', capabilities: ['PUSH_NOTIFICATIONS' as const], appGroups: ['group.com.example.notes'] }], extraBundleIds: ['com.example.notes.watch'] } } };
const p = project(cfg, files);
const s = discoverTargets(p.root, p.loaded.config);
expect(s.extensions.map(t => t.bundleId)).toEqual(['com.example.notes.widget', 'com.example.notes.watch']);
expect(s.extensions[0]).toMatchObject({ source: 'config', appGroups: ['group.com.example.notes'], capabilities: [{ type: 'PUSH_NOTIFICATIONS' }] });
});
test('validateSigning checks the extensions override', () => {
expect(validateSigning({ extensions: [{ bundleId: 'com.example.app.share', capabilities: ['APP_GROUPS'], appGroups: ['group.com.example.app'] }] })).toEqual([]);
const e = validateSigning({ extensions: [{ bundleId: 'bad id', capabilities: ['NOPE' as never], appGroups: ['x'] }, { bundleId: 'com.example.a', capabilities: ['NOPE' as never], appGroups: ['x'] }] });
expect(e.join('\n')).toContain('"bad id" is not a bundle id');
expect(e.join('\n')).toContain('unknown capability "NOPE"');
expect(e.join('\n')).toContain('appGroups are ids like group.');
});
});
describe('App Group check', () => {
const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00trailer`, 'latin1').toString('base64');
const profile = (ent: Record) => ({ id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z', profileContent: prov(ent) } });
test('the profile plist is decoded and compared with the target\'s groups', () => {
const ent = profileEntitlements([profile({ 'application-identifier': 'ABCDE12345.com.example.notes.share', 'com.apple.security.application-groups': ['group.com.example.other'] })])!;
expect(appGroupGaps(['group.com.example.notes', 'group.com.example.other'], ent)).toEqual(['group.com.example.notes']);
expect(appGroupGaps(['group.com.example.notes'], { 'com.apple.security.application-groups': ['*'] })).toEqual([]);
expect(appGroupGaps(['group.com.example.notes'], {})).toEqual(['group.com.example.notes']);
});
test('a managed or contentless profile is unknown, not a gap', () => {
expect(profileEntitlements([{ id: 'M', attributes: { profileType: 'IOS_APP_STORE', profileState: 'ACTIVE' }, managed: true }])).toBeNull();
expect(appGroupGapsFor([{ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }], () => null, () => 'X')).toEqual([]);
});
test('the gap carries the portal deep link and the agent-first steps with a never-press list', () => {
const g = appGroupGapsFor([{ bundleId: 'com.example.notes.share', appGroups: ['group.com.example.notes'] }], () => ({}), () => 'EXT0001');
expect(g).toEqual([{ bundleId: 'com.example.notes.share', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001' }]);
expect(bundleIdPortalUrl('EXT0001')).toBe(g[0]!.url!);
const steps = appGroupSteps(g[0]!).join('\n');
expect(steps).toContain(g[0]!.url!);
expect(steps).toContain('Never press');
expect(steps).toContain('signing ensure --refresh-profile');
});
test('planProfile: --refresh-profile makes a new profile although an active one is reusable', () => {
const existing = [{ id: 'P1', certIds: ['C1'], attributes: { name: 'old', profileType: 'IOS_APP_STORE' as const, profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z' } }];
const o = { bundleId: 'com.example.notes', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const };
expect(planProfile(existing, o).reuse?.id).toBe('P1');
const r = planProfile(existing, { ...o, refresh: true });
expect(r.reuse).toBeNull();
expect(r.create?.path).toBe('/v1/profiles');
expect(r.why).toContain('--refresh-profile');
});
});
describe('signing ensure with an extension (fake App Store Connect)', () => {
beforeEach(() => { upsertTiming.waitMs = 0; });
afterEach(() => { restore(); upsertTiming.waitMs = 3000; });
const MAIN = 'BUNDLE0001', EXT = 'EXT0001';
const cfg = (style: 'automatic' | 'manual') => ({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style, capabilities: ['IN_APP_PURCHASE' as const] } } });
const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64');
function store(o: { groupInProfile: boolean }) {
const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: [] };
let registered = false;
const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' },
relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } });
const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: null } }));
const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } });
const routes = [
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => (registered ? page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) : page([])) },
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) },
{ method: 'POST', match: /^\/v1\/bundleIds$/, answer: () => { registered = true; return { data: bundle(EXT, 'com.example.notes.ShareExt') }; } },
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => {
const a = (c.body as { data: { attributes: { capabilityType: string }; relationships: { bundleId: { data: { id: string } } } } }).data;
caps[a.relationships.bundleId.data.id]!.push(a.attributes.capabilityType);
return { data: { type: 'bundleIdCapabilities', id: 'x', attributes: { capabilityType: a.attributes.capabilityType } } };
} },
{ match: /^\/v1\/certificates\?/, answer: page([]) },
{ method: 'POST', match: /^\/v1\/certificates$/, answer: { data: { type: 'certificates', id: 'CERT0001', attributes: { certificateType: 'DISTRIBUTION', name: 'Apple Distribution: Example', expirationDate: '2099-01-01T00:00:00.000+00:00', certificateContent: Buffer.from('cert').toString('base64') } } } },
{ match: /^\/v1\/bundleIds\/[A-Z0-9]+\/profiles/, answer: page([]) },
{ method: 'POST', match: /^\/v1\/profiles$/, answer: (c: { body: unknown }) => {
const id = (c.body as { data: { relationships: { bundleId: { data: { id: string } } } } }).data.relationships.bundleId.data.id;
const groups = o.groupInProfile ? { 'com.apple.security.application-groups': ['group.com.example.notes'] } : {};
return { data: { type: 'profiles', id: `PROF_${id}`, attributes: { name: `storebound ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov({ 'application-identifier': `ABCDE12345.${id}`, ...groups }) } } };
} },
{ match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 },
];
return { routes: routes as never, caps, isRegistered: () => registered };
}
test('--dry lists every target: the extension bundle id, its capabilities and (manual) its profile', async () => {
ascEnv();
const p = project(cfg('manual'), files);
const s = store({ groupInProfile: true });
const calls = mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
const all = r.out + r.err;
expect(all).toContain('POST /v1/bundleIds');
expect(all).toContain('register com.example.notes.ShareExt');
expect(all).toMatch(/POST \/v1\/bundleIdCapabilities \{capabilityType="APP_GROUPS"\} # com\.example\.notes\.ShareExt, after it is registered/);
expect(all).toContain('ASSOCIATED_DOMAINS');
expect(all).toContain('# com.example.notes.ShareExt: with the new certificate');
expect(all).not.toContain('ASSOCIATED_DOMAINS is in ios/App/Share/Share.entitlements'); // the extension's entitlements are not compared with the app's App ID
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
});
test('automatic: registers the extension bundle id and its capabilities, reads them back, no profile', async () => {
ascEnv();
const p = project(cfg('automatic'), files);
const s = store({ groupInProfile: true });
const calls = mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--yes'])));
expect(r.exit).toBeNull();
const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`);
expect(writes[0]).toBe('POST /v1/bundleIds');
expect(writes.filter(w => w === 'POST /v1/bundleIdCapabilities')).toHaveLength(2);
expect(writes.some(w => /profiles|certificates/.test(w))).toBe(false);
expect(s.caps.EXT0001!.sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']);
const reg = calls.find(c => c.method === 'POST' && c.path === '/v1/bundleIds')!.body as { data: { attributes: Record } };
expect(reg.data.attributes).toMatchObject({ identifier: 'com.example.notes.ShareExt', platform: 'IOS', name: 'Example Notes ShareExt' });
});
test('manual: a profile for the app and the extension; the group missing from the profile fails with the portal link and steps', async () => {
ascEnv();
const p = project(cfg('manual'), files);
const s = store({ groupInProfile: false });
const calls = mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 }));
const all = r.out + r.err;
expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2);
expect(all).toContain('com.example.notes.ShareExt lacks group.com.example.notes');
expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001');
expect(all).toContain('signing ensure --refresh-profile');
});
test('manual: the profile carries the group: ensure finishes', async () => {
ascEnv();
const p = project(cfg('manual'), files);
const s = store({ groupInProfile: true });
mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install'])));
expect(r.exit).toBeNull();
expect(r.out + r.err).toContain('signing ready');
});
});
describe('apple build over SSH: the keychain fixes', () => {
test('the refusal names the GUI session, the CI runner and the keychain flags', () => {
const m = sshBuildRefusal('this is an SSH session');
expect(m).toContain('errSecInternalComponent');
expect(m).toContain('open -a Terminal');
expect(m).toContain('Jenkins');
expect(m).toContain('security unlock-keychain');
expect(m).toContain('security set-key-partition-list');
expect(m).toContain('--keychain-unlock');
expect(m).toContain('--keychain ');
expect(m).toContain(KEYCHAIN_PASSWORD_ENV);
expect(sshBuildRefusal('x', { unlockFlag: false })).not.toContain('--keychain-unlock');
});
test('unlock steps carry a PASSWORD placeholder that is never shown; the build keychain joins the search list', () => {
const steps = unlockSteps('/tmp/build.keychain-db', ['/Users/example/Library/Keychains/login.keychain-db']);
expect(steps.map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list', 'list-keychains']);
expect(steps[1]).toContain('apple-tool:,apple:,codesign:');
expect(steps[2]).toEqual(['security', 'list-keychains', '-d', 'user', '-s', '/tmp/build.keychain-db', '/Users/example/Library/Keychains/login.keychain-db']);
expect(showSecurity(steps[0]!)).toBe(`security unlock-keychain -p $${KEYCHAIN_PASSWORD_ENV} /tmp/build.keychain-db`);
expect(unlockSteps('/a', ['/a']).map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list']);
expect(parseKeychainList(' "/Users/example/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n')).toEqual(['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']);
});
test('the password comes from the environment; its absence is an error that names the variable only', () => {
expect(() => keychainPassword({})).toThrow(KEYCHAIN_PASSWORD_ENV);
expect(keychainPassword({ [KEYCHAIN_PASSWORD_ENV]: 'pw-for-test' })).toBe('pw-for-test');
try { keychainPassword({}); } catch (e) { expect((e as Error).message).not.toContain('pw-for-test'); }
});
});
describe('storebound api writes: --yes --confirm ', () => {
const w = { store: 'apple' as const, method: 'POST', kind: 'write' as const, opId: 'bundleIds_createInstance', yes: false, tty: false, dry: false, agent: false };
test('the usage and the refusals show the example', () => {
expect(USAGE).toContain('--yes --confirm bundleIds_createInstance');
expect(NOT_A_TTY).toContain('--yes --confirm bundleIds_createInstance');
const d = decide(w as never);
expect(d.action).toBe('dry');
const why = (d as { why: string }).why;
expect(why).toContain('--yes --confirm bundleIds_createInstance');
expect(why).toContain('storebound api apple bundleIds_createInstance --body body.json');
});
});