216 lines
15 KiB
TypeScript
216 lines
15 KiB
TypeScript
// Bundle id capabilities from real use (STAR DASH 1.0.1, October 2026): automatic signing must not create profiles or
|
|
// certificates, a capability write is read back (lag, never sticking), the App ID / config / project entitlements
|
|
// comparison, unused storebound profiles, and the `api` write message naming the dedicated command. Answers follow
|
|
// the live bundleIds?include=bundleIdCapabilities shape (capability id = <bundleIdId>_<TYPE>, settings null); every
|
|
// id is a placeholder.
|
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import { writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { ascClient } from '../cli/apple/asc';
|
|
import { capabilityReport, printCapabilityReport, run as signing } from '../cli/apple/signing';
|
|
import { buildEntitlements, compareCapabilities, entitlementKeys, ensureScope, machoEntitlements, projectEntitlements, unusedStoreboundProfiles, verifyCapabilities } from '../cli/lib/signing';
|
|
import { toXmlPlist } from '../cli/lib/plist';
|
|
import { makeZip } from './build-fixtures';
|
|
import { dedicatedCommand, decide } from '../cli/api/gate';
|
|
import { upsertTiming } from '../cli/lib/upsert';
|
|
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
|
|
|
|
const BID = 'BUNDLE0001';
|
|
const cap = (t: string) => ({ type: 'bundleIdCapabilities', id: `${BID}_${t}`, attributes: { settings: null, capabilityType: t } });
|
|
const bundles = (types: string[]) => ({
|
|
data: [{ type: 'bundleIds', id: BID, attributes: { identifier: 'com.example.notes', platform: 'UNIVERSAL', seedId: 'ABCDE12345' },
|
|
relationships: { bundleIdCapabilities: { data: types.map(t => ({ type: 'bundleIdCapabilities', id: `${BID}_${t}` })) } } }],
|
|
included: types.map(cap), links: { self: '' }, meta: { paging: { total: 1, limit: 200 } },
|
|
});
|
|
const page = (data: unknown[]) => ({ data, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } });
|
|
const sbProfile = { type: 'profiles', id: 'PROF0001', attributes: { name: 'storebound com.example.notes App Store 2026-10-14 10:45', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2027-09-15T01:29:54.000+00:00', uuid: 'profile-uuid-1' } };
|
|
const ENT = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<plist version="1.0"><dict>
|
|
<!-- <key>com.apple.developer.healthkit</key> commented out -->
|
|
<key>aps-environment</key><string>development</string>
|
|
<key>com.apple.developer.game-center</key><true/>
|
|
<key>com.apple.developer.applesignin</key><false/>
|
|
</dict></plist>`;
|
|
const PBX = (ref: boolean) => `// !$*UTF8*$!\n{ buildSettings = { CODE_SIGN_STYLE = Automatic; ${ref ? 'CODE_SIGN_ENTITLEMENTS = App/App.entitlements;' : ''} }; }\n`;
|
|
|
|
beforeEach(() => { upsertTiming.waitMs = 0; });
|
|
afterEach(() => { restore(); upsertTiming.waitMs = 3000; });
|
|
|
|
describe('entitlements and the three-way comparison', () => {
|
|
test('entitlementKeys skips comments and <false/> keys', () => {
|
|
expect(entitlementKeys(ENT)).toEqual(['aps-environment', 'com.apple.developer.game-center']);
|
|
expect(projectEntitlements('App/App.entitlements', ENT, true).caps.map(c => c.cap)).toEqual(['PUSH_NOTIFICATIONS', 'GAME_CENTER']);
|
|
});
|
|
|
|
test('names what is missing where', () => {
|
|
const proj = [projectEntitlements('ios/App/App/App.entitlements', ENT, true)];
|
|
const m = compareCapabilities({ appId: ['IN_APP_PURCHASE'], config: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS'], project: proj });
|
|
const s = m.map(x => `${x.cap}:${x.missingIn}`);
|
|
expect(s).toContain('PUSH_NOTIFICATIONS:app-id'); // config wants it, App ID lacks it
|
|
expect(s).toContain('GAME_CENTER:app-id'); // entitlement without App ID capability
|
|
expect(s).toContain('GAME_CENTER:config'); // entitlement not in the config
|
|
expect(s).not.toContain('IN_APP_PURCHASE:project'); // IAP needs no entitlement
|
|
expect(m.find(x => x.cap === 'GAME_CENTER' && x.missingIn === 'app-id')!.msg).toContain('com.apple.developer.game-center');
|
|
});
|
|
|
|
test('config capabilities without entitlements, and an entitlements file the project does not reference', () => {
|
|
const none = compareCapabilities({ appId: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [] });
|
|
expect(none.map(x => `${x.cap}:${x.missingIn}`)).toEqual(['PUSH_NOTIFICATIONS:project', 'GAME_CENTER:project']);
|
|
expect(none[0]!.msg).toContain('aps-environment');
|
|
const unref = compareCapabilities({ appId: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [projectEntitlements('App/App.entitlements', ENT, false)] });
|
|
expect(unref.map(x => x.msg).join('\n')).toContain('not referenced by CODE_SIGN_ENTITLEMENTS');
|
|
expect(compareCapabilities({ appId: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [projectEntitlements('App/App.entitlements', ENT, true)] })).toEqual([]);
|
|
expect(compareCapabilities({ appId: ['IN_APP_PURCHASE'], config: ['GAME_CENTER'], project: null }).map(x => x.missingIn)).toEqual(['app-id']);
|
|
});
|
|
});
|
|
|
|
describe('read-back after a capability write', () => {
|
|
test('lag: the third read has it', async () => {
|
|
const reads = [['IN_APP_PURCHASE'], ['IN_APP_PURCHASE'], ['IN_APP_PURCHASE', 'GAME_CENTER']];
|
|
const r = await verifyCapabilities(async () => reads.shift() ?? [], ['IN_APP_PURCHASE', 'GAME_CENTER'], { waitMs: 0, sleep: async () => {} });
|
|
expect(r).toEqual({ have: ['IN_APP_PURCHASE', 'GAME_CENTER'], missing: [], reads: 3 });
|
|
});
|
|
|
|
test('stops at the timeout', async () => {
|
|
const r = await verifyCapabilities(async () => ['IN_APP_PURCHASE'], ['GAME_CENTER'], { tries: 50, waitMs: 1000, timeoutMs: 1500, sleep: async () => {} });
|
|
expect(r.missing).toEqual(['GAME_CENTER']);
|
|
expect(r.reads).toBeLessThan(50);
|
|
});
|
|
});
|
|
|
|
describe('apple signing ensure', () => {
|
|
const cfg = (style?: 'automatic' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, signing: { ...(style ? { style } : {}), capabilities: ['IN_APP_PURCHASE' as const, 'PUSH_NOTIFICATIONS' as const, 'GAME_CENTER' as const] } } });
|
|
const routes = (after: string[]) => [
|
|
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(['IN_APP_PURCHASE']), times: 1 },
|
|
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(after) },
|
|
{ match: /^\/v1\/certificates\?/, answer: page([]) },
|
|
{ match: /^\/v1\/bundleIds\/BUNDLE0001\/profiles/, answer: page([sbProfile]) },
|
|
{ match: /^\/v1\/profiles\/PROF0001\/relationships\/certificates/, answer: page([{ type: 'certificates', id: 'CERT0001' }]) },
|
|
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => ({ data: cap((c.body as { data: { attributes: { capabilityType: string } } }).data.attributes.capabilityType) }) },
|
|
];
|
|
|
|
test('automatic: --dry plans only the capabilities and says why; lists the unused storebound profile with its delete command', async () => {
|
|
ascEnv();
|
|
const p = project(cfg(), { 'ios/App/App.xcodeproj/project.pbxproj': PBX(true), 'ios/App/App/App.entitlements': ENT });
|
|
const calls = mockFetch(routes(['IN_APP_PURCHASE']));
|
|
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
|
|
const all = r.out + r.err;
|
|
expect(all).toContain('automatic signing: ensure only keeps the App ID');
|
|
expect(all).toContain('would POST /v1/bundleIdCapabilities');
|
|
expect(all).not.toMatch(/POST \/v1\/(profiles|certificates)/);
|
|
expect(all).toContain('storebound api apple profiles_deleteInstance --param id=PROF0001');
|
|
expect(all).toContain('PUSH_NOTIFICATIONS is in apple.signing.capabilities but not on the App ID');
|
|
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
|
|
});
|
|
|
|
test('automatic: a real run adds capabilities, reads them back and creates no certificate or profile', async () => {
|
|
ascEnv();
|
|
const p = project(cfg('automatic'));
|
|
const calls = mockFetch(routes(['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER']));
|
|
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).toEqual(['POST /v1/bundleIdCapabilities', 'POST /v1/bundleIdCapabilities']);
|
|
expect(r.out + r.err).toContain('read back: the App ID has IN_APP_PURCHASE, PUSH_NOTIFICATIONS, GAME_CENTER');
|
|
});
|
|
|
|
test('a write that never sticks fails with the API answers', async () => {
|
|
ascEnv();
|
|
const p = project(cfg());
|
|
mockFetch(routes(['IN_APP_PURCHASE']));
|
|
const r = await capture(() => signing(p.ctx(['ensure', '--yes']))).catch(e => ({ out: '', err: String(e), exit: 1 }));
|
|
expect(r.err).toContain('capability write did not stick');
|
|
expect(r.err).toContain('PUSH_NOTIFICATIONS, GAME_CENTER');
|
|
expect(r.err).toContain(`${BID}_GAME_CENTER`); // the POST answers are in the message
|
|
});
|
|
});
|
|
|
|
describe('scope and leftovers', () => {
|
|
test('ensureScope', () => {
|
|
expect(ensureScope(undefined)).toMatchObject({ certificates: false, profiles: false });
|
|
expect(ensureScope('manual')).toMatchObject({ certificates: true, profiles: true });
|
|
});
|
|
test('unusedStoreboundProfiles keeps Xcode-managed and in-use ones', () => {
|
|
const managed = { id: 'X', attributes: { name: 'iOS Team Store Provisioning Profile: com.example.notes' }, managed: true };
|
|
expect(unusedStoreboundProfiles([sbProfile, managed], new Set()).map(p => p.id)).toEqual(['PROF0001']);
|
|
expect(unusedStoreboundProfiles([sbProfile], new Set(['PROF0001']))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('storebound api writes outside a terminal', () => {
|
|
test('the dry reason says TTY by design and the dedicated command is named', () => {
|
|
const d = decide({ kind: 'write', method: 'POST', dry: false, yes: true, getOnly: false, tty: false, agent: false });
|
|
expect(d).toMatchObject({ action: 'dry' });
|
|
expect((d as { why: string }).why).toContain('by design');
|
|
expect(dedicatedCommand('apple', 'bundleIdCapabilities_createInstance')).toContain('apple signing ensure');
|
|
expect(dedicatedCommand('apple', 'apps_getInstance')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// STAR DASH (October 2026): main keeps native changes off on purpose (OTA fingerprint); the entitlements live on a
|
|
// release branch and the uploaded build has them. The checkout warning must say so, never claim the build lacks them,
|
|
// and the built IPA's real entitlements are compared when one exists.
|
|
describe('entitlements on another branch, and the built IPA', () => {
|
|
const git = (root: string, ...args: string[]) => {
|
|
const r = Bun.spawnSync(['git', '-C', root, '-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
if (!r.success) throw new Error(r.stderr.toString());
|
|
};
|
|
const caps = ['IN_APP_PURCHASE' as const, 'PUSH_NOTIFICATIONS' as const, 'GAME_CENTER' as const];
|
|
const cfg = { ...BASE, apple: { ...BASE.apple!, signing: { capabilities: caps } } };
|
|
const appIdRoutes = () => [{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(caps) }];
|
|
const PROD = ENT.replace('development', 'production');
|
|
// a fake Mach-O with an embedded entitlements blob (0xfade7171, big-endian length, XML)
|
|
const machO = (xml: string) => {
|
|
const body = Buffer.from(xml), head = Buffer.alloc(8);
|
|
head.writeUInt32BE(0xfade7171, 0); head.writeUInt32BE(body.length + 8, 4);
|
|
return Buffer.concat([Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.alloc(60), head, body, Buffer.alloc(16)]);
|
|
};
|
|
const info = (build: string) => toXmlPlist({ CFBundleIdentifier: 'com.example.notes', CFBundleShortVersionString: '1.0.1', CFBundleVersion: build, CFBundleExecutable: 'App' });
|
|
const ipa = (build: string, xml: string | null) => makeZip([
|
|
{ name: 'Payload/App.app/Info.plist', data: info(build) },
|
|
...(xml === null ? [] : [{ name: 'Payload/App.app/App', data: machO(xml) }]),
|
|
]);
|
|
|
|
test('machoEntitlements reads the blob', () => {
|
|
expect(machoEntitlements(machO(PROD))).toEqual([PROD]);
|
|
expect(machoEntitlements(Buffer.from('no signature here'))).toEqual([]);
|
|
});
|
|
|
|
test('no entitlements on this branch: a checkout warning naming the branch and the one that has them, never a build claim', async () => {
|
|
ascEnv();
|
|
const p = project(cfg, { 'ios/App/App.xcodeproj/project.pbxproj': PBX(false), 'ios/App/App/AppDelegate.swift': '' });
|
|
git(p.root, 'init', '-q', '-b', 'main'); git(p.root, 'add', '-A'); git(p.root, 'commit', '-qm', 'main');
|
|
git(p.root, 'checkout', '-qb', 'release/1.0.1');
|
|
writeFileSync(join(p.root, 'ios/App/App/App.entitlements'), PROD, { flag: 'w' });
|
|
git(p.root, 'add', '-A'); git(p.root, 'commit', '-qm', 'native'); git(p.root, 'checkout', '-q', 'main');
|
|
mockFetch(appIdRoutes());
|
|
const r = await capabilityReport(await ascClient(p.root, "com.example.notes"), p.loaded.config, p.root);
|
|
expect(r.checkout).toMatchObject({ branch: 'main' });
|
|
const push = r.mismatches.find(m => m.cap === 'PUSH_NOTIFICATIONS')!;
|
|
expect(push.msg).toContain("this checkout's iOS project on branch main has no entitlements for aps-environment (built from another branch?)");
|
|
expect(push.msg).toContain('branch release/1.0.1 (ios/App/App/App.entitlements)');
|
|
expect(push.msg).toContain('does not expose a build');
|
|
expect(push.msg).not.toContain('ship without');
|
|
});
|
|
|
|
test('a built IPA: its real entitlements are compared and labelled with its build number', async () => {
|
|
ascEnv();
|
|
const p = project(cfg, {
|
|
'ios/App/App.xcodeproj/project.pbxproj': PBX(false),
|
|
'build/ios/export/old/App.ipa': ipa('6', ENT.replace(/<key>com.apple.developer.game-center[^\n]*\n/, '')),
|
|
'build/ios/export/App.ipa': ipa('7', PROD),
|
|
'build/ios/export/broken.ipa': ipa('5', null),
|
|
});
|
|
mockFetch(appIdRoutes());
|
|
const r = await capabilityReport(await ascClient(p.root, "com.example.notes"), p.loaded.config, p.root);
|
|
expect(r.build).toMatchObject({ number: '7', file: 'build/ios/export/App.ipa' });
|
|
expect(r.build!.caps.map(c => c.cap)).toEqual(['PUSH_NOTIFICATIONS', 'GAME_CENTER']);
|
|
expect(r.mismatches.filter(m => m.missingIn === 'build')).toEqual([]);
|
|
expect(r.mismatches.find(m => m.cap === 'GAME_CENTER')!.msg).toContain('uploaded build 7 has it');
|
|
// the build without game-center is flagged as the build, by number
|
|
const only6 = compareCapabilities({ appId: caps, config: caps, project: [], build: buildEntitlements('6', 'x.ipa', ENT.replace(/<key>com.apple.developer.game-center[^\n]*\n/, ''), 'mach-o') });
|
|
expect(only6.find(m => m.missingIn === 'build')!.msg).toContain('uploaded build 6 (x.ipa) has no com.apple.developer.game-center');
|
|
const out = await capture(async () => printCapabilityReport(r, 'apple'));
|
|
expect(out.out + out.err).toContain('uploaded build 7 (build/ios/export/App.ipa');
|
|
});
|
|
});
|