// 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, appGroupVerdicts, bundleIdPortalUrl, capabilityGroups, pbxTargets, targetKind } from '../cli/lib/targets'; import { consolePlan, planText } from '../cli/lib/console-plan'; import { verifyAppGroupsApi, verifyText } from '../cli/lib/console-verify'; import { PLAN_FORM_IDS } from '../cli/lib/console-answers'; import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools'; 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: the newest profile lacks App Group(s) group.com.example.notes'); expect(all).toContain('console plan apple-app-group --bundle-id 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('App Group association: verdicts (pure)', () => { const t = [{ bundleId: 'com.example.notes.ShareExt', appGroups: ['group.com.example.notes'] }]; const ent = (g: string[]) => ({ 'com.apple.security.application-groups': g }); test('a readable profile with the group proves it; one without it is a gap with the deep link', () => { expect(appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, profileEnt: ent(['group.com.example.notes']) }))).toEqual({ gaps: [], unproven: [], ok: ['com.example.notes.ShareExt'] }); const v = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, profileEnt: {} })); expect(v.gaps).toHaveLength(1); expect(v.gaps[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'], url: bundleIdPortalUrl('EXT0001') }); expect(v.gaps[0]!.why).toContain('empty application-groups'); }); test('capability settings that name the group prove it without a profile', () => { const settings = [{ key: 'APP_GROUPS', options: [{ key: 'group.com.example.notes' }] }]; expect(capabilityGroups(settings, ['group.com.example.notes', 'group.com.example.other'])).toEqual(['group.com.example.notes']); expect(appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, capabilitySettings: settings, profileEnt: null })).ok).toEqual(['com.example.notes.ShareExt']); }); test('nothing readable is unproven (not a gap); the capability off is a gap even without a profile', () => { const u = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: true, capabilitySettings: null, profileEnt: null })); expect([u.gaps.length, u.unproven.length]).toEqual([0, 1]); expect(u.unproven[0]!.why).toContain('cannot be proven'); expect(appGroupVerdicts(t, () => null).unproven).toHaveLength(1); const off = appGroupVerdicts(t, () => ({ resourceId: 'EXT0001', capabilityOn: false, profileEnt: null })); expect(off.gaps[0]!.why).toContain('capability is not enabled'); }); test('the steps name the portal path, the deep link, the plan command and the refresh', () => { const s = appGroupSteps({ bundleId: 'com.example.notes.ShareExt', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: bundleIdPortalUrl('EXT0001') }).join('\n'); expect(s).toContain('Certificates, Identifiers & Profiles > Identifiers > com.example.notes.ShareExt > App Groups'); expect(s).toContain('Configure > tick group.com.example.notes'); expect(s).toContain('Continue > Save'); expect(s).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); expect(s).toContain('storebound apple signing ensure --refresh-profile'); }); }); describe('App Group association under automatic signing (fake App Store Connect)', () => { beforeEach(() => { upsertTiming.waitMs = 0; }); afterEach(() => { restore(); upsertTiming.waitMs = 3000; }); const MAIN = 'BUNDLE0001', EXT = 'EXT0001'; const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style: 'automatic' as const, teamId: 'ABCDE12345', 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'); const managed = (id: string, ent: Record) => ({ type: 'profiles', id: `MANAGED_${id}`, attributes: { name: `iOS Team Store Provisioning Profile: ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov(ent) } }); const GROUP = { 'com.apple.security.application-groups': ['group.com.example.notes'] }; const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); function api(o: { ext?: unknown[]; main?: unknown[]; extSettings?: unknown; extCaps?: string[] }) { const caps: Record = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: o.extCaps ?? ['APP_GROUPS', 'ASSOCIATED_DOMAINS'] }; 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: id === EXT && t === 'APP_GROUPS' ? (o.extSettings ?? null) : null } })); return [ { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) }, { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) }, { match: /^\/v1\/certificates\?/, answer: page([]) }, { match: new RegExp(`^/v1/bundleIds/${EXT}/profiles`), answer: () => page(o.ext ?? []) }, { match: new RegExp(`^/v1/bundleIds/${MAIN}/profiles`), answer: () => page(o.main ?? [managed('com.example.notes', GROUP)]) }, { match: /^\/v1\/profiles\/MANAGED_[^/]+\/relationships\/certificates/, answer: { errors: [{ status: '404' }] }, status: 404 }, { match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 }, ] as never; } const ensure = async (argv: string[] = ['ensure', '--yes']) => { const p = project(cfg, files); const calls = mockFetch(api(current)); const r = await capture(() => signing(p.ctx(argv))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); return { r, all: r.out + r.err, calls }; }; let current: Parameters[0] = {}; test('the extension\'s newest (Xcode-managed) profile has an empty group list: ensure stops with the steps, the deep link and the plan command', async () => { ascEnv(); current = { ext: [managed('com.example.notes.ShareExt', {})] }; const { r, all, calls } = await ensure(); expect(r.exit).toBe(1); expect(all).toContain('com.example.notes.ShareExt: the newest profile lacks App Group(s) group.com.example.notes'); expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001'); expect(all).toContain('Configure > tick group.com.example.notes'); expect(all).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); expect(all).toContain('storebound apple signing ensure --refresh-profile'); expect(calls.filter(c => c.method !== 'GET')).toEqual([]); }); test('the managed profile carries the group: ensure finishes without a warning', async () => { ascEnv(); current = { ext: [managed('com.example.notes.ShareExt', GROUP)] }; const { r, all } = await ensure(); expect(r.exit).toBeNull(); expect(all).not.toContain('cannot be proven'); expect(all).toContain('capabilities in step'); }); test('no profile and no capability field to read: ensure warns with the portal steps instead of passing silently', async () => { ascEnv(); current = { ext: [] }; const { r, all } = await ensure(); expect(r.exit).toBeNull(); expect(all).toContain('cannot be proven'); expect(all).toContain('storebound console plan apple-app-group --bundle-id com.example.notes.ShareExt'); }); test('a capability field that names the group proves it', async () => { ascEnv(); current = { ext: [], extSettings: [{ key: 'APP_GROUPS', options: [{ key: 'group.com.example.notes' }] }] }; const { all } = await ensure(); expect(all).not.toContain('cannot be proven'); }); test('status --json lists the gap and the unproven targets; the main app (profile without groups) is a gap too', async () => { ascEnv(); current = { ext: [], main: [managed('com.example.notes', {})] }; const p = project(cfg, files); mockFetch(api(current)); const r = await capture(() => signing(p.ctx(['status', '--json']))); const j = JSON.parse(r.out.slice(r.out.indexOf('{'))); expect(j.appGroupGaps.map((g: { bundleId: string }) => g.bundleId)).toEqual(['com.example.notes']); expect(j.appGroupUnproven.map((g: { bundleId: string }) => g.bundleId)).toEqual(['com.example.notes.ShareExt']); }); test('apple build stops before the archive on a proven gap, builds on --allow-app-group-gap, and only warns on an unproven one', async () => { ascEnv(); const { run: build } = await import('../cli/apple/build'); const argv = ['--build-number', '5', '--no-prebuild', '--dry']; // dry: warns (never throws) current = { ext: [managed('com.example.notes.ShareExt', {})] }; let p = project(cfg, files); mockFetch(api(current)); let r = await capture(() => build(p.ctx(argv))); expect(r.err).toContain('the newest profile lacks App Group(s)'); // not dry: early exit, before any xcodebuild step p = project(cfg, files); mockFetch(api(current)); r = await capture(() => build(p.ctx(['--build-number', '5', '--no-prebuild']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 })); expect(r.exit).toBe(1); expect(r.err).toContain('stopped before the archive'); expect(r.err).toContain('console plan apple-app-group --bundle-id com.example.notes.ShareExt'); expect(r.err).toContain('--allow-app-group-gap'); // unproven: a warning with the same steps, no throw at the group check current = { ext: [] }; p = project(cfg, files); mockFetch(api(current)); r = await capture(() => build(p.ctx(argv))); expect(r.err).toContain('cannot be proven'); }); }); describe('console plan / verify apple-app-group', () => { afterEach(() => { restore(); }); test('the plan: deep link per resource id, the portal labels, the answer sheet per extension, never-press list, Save is the agent\'s', () => { const p = consolePlan('apple-app-group', BASE, { appGroupTargets: [{ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'], resourceId: 'EXT0001' }] }); expect(p).toMatchObject({ form: 'apple-app-group', store: 'apple', fillable: true }); expect(p.start).toEqual({ url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001', deepLink: true, reach: [] }); const text = p.steps.map(s => s.text).join('\n'); for (const label of ['App Groups', 'Configure', 'Continue', 'Save', 'Confirm']) expect(text).toContain(label); expect(p.answers.map(a => a.id)).toEqual(['com.example.notes.ShareExt/capability', 'com.example.notes.ShareExt/group.com.example.notes']); expect(p.answers[1]).toMatchObject({ label: 'group.com.example.notes', action: 'tick', control: 'checkbox', status: 'certain' }); const inSteps = new Set(p.steps.flatMap(s => s.answers ?? [])); for (const a of p.answers) expect(inSteps.has(a.id)).toBe(true); for (const b of ['Remove', 'Delete', 'Revoke', 'Publish', 'Submit']) expect(p.buttons.neverPress).toContain(b); expect(p.buttons.neverPress).not.toContain('Save'); expect(p.buttons).toMatchObject({ final: 'Save', finalByAgent: true }); for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit'); expect(p.ownerBatch).toEqual([]); expect(p.verify.command).toBe('storebound console verify apple-app-group --bundle-id com.example.notes.ShareExt'); expect(planText(p)).toContain('the agent\'s own, not a store submission'); p.steps.forEach((s, i) => expect(s.n).toBe(i + 1)); }); test('without a resource id the plan starts at the Identifiers list and says --lookup; without targets the group ids are asked', () => { const p = consolePlan('apple-app-group', BASE, { appGroupTargets: [{ bundleId: 'com.example.notes.ShareExt', groups: ['group.com.example.notes'] }] }); expect(p.start).toMatchObject({ url: 'https://developer.apple.com/account/resources/identifiers/list', deepLink: false }); expect(p.start.reach.join(' ')).toContain('--lookup'); const none = consolePlan('apple-app-group', BASE); expect(none.answers[0]).toMatchObject({ status: 'ask-owner', value: null }); expect(none.askOwner).toHaveLength(1); }); test('the plan is in the form list and the MCP tools, and the plan command works from the CLI', async () => { expect(PLAN_FORM_IDS).toContain('apple-app-group'); expect(CONSOLE_PLAN_FORMS).toContain('apple-app-group'); const t = TOOLS.find(x => x.name === 'console_plan')!; expect(validateInput(t, { form: 'apple-app-group', bundleId: 'com.example.notes.ShareExt', lookup: true })).toEqual([]); expect(t.args!({ form: 'apple-app-group', bundleId: 'com.example.notes.ShareExt', groups: 'group.com.example.notes' })).toEqual(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt', '--groups', 'group.com.example.notes', '--json']); const { run: plan } = await import('../cli/console/plan'); const p = project({ ...BASE, apple: { bundleId: 'com.example.notes' } }, files); const r = await capture(() => plan(p.ctx(['apple-app-group', '--json']))); const j = JSON.parse(r.out); expect(j.answers.map((a: { id: string }) => a.id)).toEqual(['com.example.notes/capability', 'com.example.notes/group.com.example.notes', 'com.example.notes.ShareExt/capability', 'com.example.notes.ShareExt/group.com.example.notes']); const one = await capture(() => plan(p.ctx(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt', '--json']))); expect(JSON.parse(one.out).answers).toHaveLength(2); }); test('verify reads the API: present groups match, a missing one mismatches, an unprovable one fails', async () => { const targets = [{ bundleId: 'com.example.notes.ShareExt', appGroups: ['group.com.example.notes', 'group.com.example.other'] }]; const ok = verifyAppGroupsApi(targets, { gaps: [], unproven: [], ok: ['com.example.notes.ShareExt'] }); expect([ok.ok, ok.matched, ok.mismatches]).toEqual([true, 2, 0]); const gap = verifyAppGroupsApi(targets, { gaps: [{ bundleId: 'com.example.notes.ShareExt', resourceId: 'EXT0001', groups: ['group.com.example.other'], url: null, why: 'the newest profile lacks it' }], unproven: [], ok: [] }); expect([gap.ok, gap.matched, gap.mismatches]).toEqual([false, 1, 1]); expect(gap.rows.find(r => r.status === 'mismatch')!.item).toContain('group.com.example.other'); const un = verifyAppGroupsApi(targets, { gaps: [], unproven: [{ bundleId: 'com.example.notes.ShareExt', resourceId: null, groups: ['group.com.example.notes', 'group.com.example.other'], url: null }], ok: [] }); expect(un.ok).toBe(false); expect(un.rows[0]!.note).toContain('signing ensure --refresh-profile'); expect(() => verifyText('apple-app-group', BASE, 'x')).toThrow('reads the App Store Connect API'); }); test('console verify apple-app-group end to end with a fake API: missing then present after the refreshed profile', async () => { ascEnv(); const { run: verify } = await import('../cli/console/verify'); const prov = (ent: Record): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); const prof = (ent: Record) => ({ type: 'profiles', id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: 'u', expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov(ent) } }); const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } }); const run = async (ent: Record) => { mockFetch([ { match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: page([{ type: 'bundleIds', id: 'EXT0001', attributes: { identifier: 'com.example.notes.ShareExt', platform: 'IOS' }, relationships: { bundleIdCapabilities: { data: [{ type: 'bundleIdCapabilities', id: 'c1' }] } } }], [{ type: 'bundleIdCapabilities', id: 'c1', attributes: { capabilityType: 'APP_GROUPS', settings: null } }]) }, { match: /^\/v1\/bundleIds\/EXT0001\/profiles/, answer: page([prof(ent)]) }, ] as never); const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files); return capture(() => verify(p.ctx(['apple-app-group', '--bundle-id', 'com.example.notes.ShareExt']))); }; const before = await run({}); expect(before.exit).toBe(1); expect(before.out).toContain('mismatch'); restore(); ascEnv(); const after = await run({ 'com.apple.security.application-groups': ['group.com.example.notes'] }); expect(after.exit).toBeNull(); expect(after.out).toContain('match'); }); }); 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'); }); });