apple signing: App Group association fails early under automatic signing too (API capability + newest profile, steps and deep link, exit before the archive); console plan/verify apple-app-group

This commit is contained in:
CRTkafa 2026-10-25 22:34:14 +07:00
parent d978bb7a8b
commit 304407e523
20 changed files with 590 additions and 60 deletions

View file

@ -30,7 +30,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health', 'play-games-services': 'play-games-services/setup',
'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production', 'play-publish-changes': 'publishing',
};
for (const form of PLAN_ONLY_FORM_IDS) {
for (const form of PLAN_ONLY_FORM_IDS.filter(f => f.startsWith('play-'))) {
test(form, () => {
const p = consolePlan(form, base);
expect(p).toMatchObject({ form, store: 'play', fillable: true });

View file

@ -9,7 +9,11 @@ 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 { 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';
@ -280,7 +284,8 @@ describe('signing ensure with an extension (fake App Store Connect)', () => {
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('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');
});
@ -296,6 +301,236 @@ describe('signing ensure with an extension (fake App Store Connect)', () => {
});
});
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, unknown>): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record<string, string | string[]> })}\x00`, 'latin1').toString('base64');
const managed = (id: string, ent: Record<string, unknown>) => ({ 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<string, string[]> = { [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<typeof api>[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, unknown>): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record<string, string | string[]> })}\x00`, 'latin1').toString('base64');
const prof = (ent: Record<string, unknown>) => ({ 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<string, unknown>) => {
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');