// Ad-hoc distribution (apple adhoc) and Google Play internal app sharing (play share). No network: fetch is mocked, // xcodebuild is never run. Response shapes are written from the App Store Connect API and androidpublisher v3 docs. import { afterEach, describe, expect, test } from 'bun:test'; import { generateKeyPairSync } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { run as adhocRun } from '../cli/apple/adhoc'; import { deviceFromApi, exportArgv, exportOptionsPlist, maskUdid, MODERN_METHOD, parseDevicesCsv, parsePlatform, planRegister, profileCreateReq, profileCurrent, type RegisteredDevice, scrubUdids, shellLine, slotUsage, usableCertificates, validUdid, } from '../cli/lib/adhoc'; import { parseArgs } from '../cli/lib/args'; import type { Loaded, ShipConfig } from '../cli/lib/config'; import { parseShareArtifact, SHARE_HEADERS, SHARE_LIMIT, shareProblems, shareUrl } from '../cli/lib/play-share'; import { run as shareRun } from '../cli/play/share'; const PKG = 'com.example.game'; const NEW = '00008101-000A1B2C3D4E5F60'; const OLD = 'a'.repeat(39) + 'b'; const OTHER = '00008030-0011223344556677'; const GONE = '00008110-00AABBCCDDEEFF00'; // ----- fetch / env / console doubles ----- const realFetch = globalThis.fetch; const savedEnv = { ...process.env }; const tty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); afterEach(() => { globalThis.fetch = realFetch; for (const k of ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'PLAY_SERVICE_ACCOUNT', 'STOREBOUND_SERVE']) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; } if (tty) Object.defineProperty(process.stdin, 'isTTY', tty); else delete (process.stdin as { isTTY?: boolean }).isTTY; }); const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } }); interface Call { method: string; url: string; body?: unknown; headers: Record } function fake(route: (c: Call) => Response): Call[] { const calls: Call[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); if (url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'tok', expires_in: 3600 }); const c: Call = { method: (init?.method ?? 'GET').toUpperCase(), url, body: init?.body, headers: { ...(init?.headers as Record) } }; calls.push(c); return route(c); }) as typeof fetch; return calls; } function ascKey(): void { const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); process.env.ASC_KEY_ID = 'test-key'; process.env.ASC_ISSUER_ID = '00000000-0000-0000-0000-000000000000'; process.env.ASC_P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); } function playKey(): void { const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' }); } function noTty(): void { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); delete process.env.STOREBOUND_SERVE; } async function captured(fn: () => Promise): Promise { const lines: string[] = []; const o = { log: console.log, warn: console.warn, error: console.error }; console.log = console.warn = console.error = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); }; try { await fn(); } finally { Object.assign(console, o); } return lines.join('\n'); } function project(extra: Partial = {}, files: Record = {}): Loaded { const root = mkdtempSync(join(tmpdir(), 'sb-adhoc-')); for (const [p, t] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), t); } const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], apple: { bundleId: PKG, teamId: 'ABCDEFGHIJ' }, play: { packageName: PKG }, ...extra } as unknown as ShipConfig; return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; } // One page of GET /v1/devices (DevicesResponse), as documented: Device.Attributes. const DEVICE = (id: string, udid: string, deviceClass: string, status = 'ENABLED', platform = 'IOS') => ({ type: 'devices', id, attributes: { name: `Test ${deviceClass.toLowerCase()} ${id}`, platform, udid, deviceClass, status, model: 'iPhone 15', addedDate: '2026-01-02T00:00:00.000+0000' } }); const DEVICES = { data: [DEVICE('D1', OTHER, 'IPHONE'), DEVICE('D2', GONE, 'IPAD', 'DISABLED'), DEVICE('D3', 'c'.repeat(40), 'APPLE_TV')], links: { self: 'https://api.appstoreconnect.apple.com/v1/devices' } }; const CSV = `name,udid,platform\nNew phone,${NEW},IOS\nOld pad,${OLD},ios\nKnown phone,${OTHER.toLowerCase()},IOS\nDisabled pad,${GONE},IOS\n`; const ALL_UDIDS = [NEW, OLD, OTHER, GONE, 'c'.repeat(40)]; const leaks = (s: string) => ALL_UDIDS.filter(u => s.toLowerCase().includes(u.toLowerCase())); describe('UDIDs: validation and masking', () => { test('legacy 40-hex and the 8-16 form; Mac hardware UUIDs only for MAC_OS', () => { expect(validUdid(NEW)).toBe(true); expect(validUdid(OLD)).toBe(true); expect(validUdid(' ' + NEW + ' ')).toBe(true); expect(validUdid('00008101-000A1B2C3D4E5F6')).toBe(false); expect(validUdid('g'.repeat(40))).toBe(false); expect(validUdid('0000-1111')).toBe(false); const mac = '00000000-0000-0000-0000-000000000000'; expect(validUdid(mac, 'IOS')).toBe(false); expect(validUdid(mac, 'MAC_OS')).toBe(true); }); test('mask keeps 4 + 4; scrub masks UDID-shaped strings in any text', () => { expect(maskUdid(NEW)).toBe('0000…5F60'); expect(maskUdid('short')).toBe('…'); const msg = `POST /v1/devices -> 409 {"detail":"A device with number '${NEW}' already exists; also ${OLD}"}`; const s = scrubUdids(msg, [NEW]); expect(leaks(s)).toEqual([]); expect(s).toContain('0000…5F60'); expect(s).toContain('aaaa…aaab'); }); test('platform names', () => { expect([parsePlatform(undefined), parsePlatform('ios'), parsePlatform('macOS'), parsePlatform('MAC_OS'), parsePlatform('tvos')]).toEqual(['IOS', 'IOS', 'MAC_OS', 'MAC_OS', null]); }); test('CSV: rows parsed; errors name the row and never hold a full UDID', () => { expect(parseDevicesCsv(CSV).devices.map(d => d.platform)).toEqual(['IOS', 'IOS', 'IOS', 'IOS']); const bad = parseDevicesCsv(`name,udid,platform\nA,${NEW}x,IOS\n,${OLD},IOS\nB,${NEW},TVOS\nC,${OTHER},IOS\nD,${OTHER},IOS\n`); expect(bad.errors).toEqual([ 'row 2: "0000…F60x" is not a device UDID (40 hex digits, or 8 hex - 16 hex)', 'row 3: name is empty', 'row 4: platform must be IOS or MAC_OS', 'row 6: 0000…6677 is listed twice', ]); expect(leaks(bad.errors.join('\n'))).toEqual([]); }); }); describe('register plan', () => { const reg = DEVICES.data.map(deviceFromApi); test('new vs registered (case-insensitive) vs disabled', () => { const p = planRegister(parseDevicesCsv(CSV).devices, reg); expect(p.add.map(d => d.name)).toEqual(['New phone', 'Old pad']); expect(p.present.map(d => d.name)).toEqual(['Known phone']); expect(p.disabled.map(d => d.name)).toEqual(['Disabled pad']); }); test('slots per device class count disabled devices too', () => { expect(slotUsage(reg)).toEqual([ { deviceClass: 'APPLE_TV', registered: 1, limit: 100 }, { deviceClass: 'IPAD', registered: 1, limit: 100 }, { deviceClass: 'IPHONE', registered: 1, limit: 100 }]); }); }); describe('ExportOptions.plist', () => { test('automatic signing, exact XML', () => { expect(exportOptionsPlist({ method: 'release-testing', teamId: 'ABCDEFGHIJ', bundleId: PKG })).toBe(` \tdestination \texport \tmethod \trelease-testing \tsigningStyle \tautomatic \tteamID \tABCDEFGHIJ \tthinning \t<none> `); }); test('manual signing with a profile; XML escaped', () => { const x = exportOptionsPlist({ method: 'ad-hoc', teamId: 'ABCDEFGHIJ', bundleId: 'com.example.a&b', profile: `Ad "Q" & 'x'` }); expect(x).toContain('\tprovisioningProfiles\n\t\n\t\tcom.example.a&b\n\t\tAd <Hoc> "Q" & 'x'\n\t\n'); expect(x).toContain('signingStyle\n\tmanual'); expect(x).toContain('ad-hoc'); expect(() => exportOptionsPlist({ method: 'ad-hoc', teamId: 'ABCDEFGHIJ', profile: 'P' })).toThrow('apple.bundleId'); }); test('deprecated method names map to the current ones', () => { expect(MODERN_METHOD).toEqual({ 'ad-hoc': 'release-testing', development: 'debugging', 'app-store': 'app-store-connect' }); }); test('xcodebuild argv and the printed line', () => { const argv = exportArgv('build/My App.xcarchive', '/p/build/ExportOptions.plist', '/p/build/adhoc'); expect(argv).toEqual(['xcodebuild', '-exportArchive', '-archivePath', 'build/My App.xcarchive', '-exportOptionsPlist', '/p/build/ExportOptions.plist', '-exportPath', '/p/build/adhoc', '-allowProvisioningUpdates']); expect(shellLine(argv)).toBe(`xcodebuild -exportArchive -archivePath 'build/My App.xcarchive' -exportOptionsPlist /p/build/ExportOptions.plist -exportPath /p/build/adhoc -allowProvisioningUpdates`); }); }); describe('profile pieces', () => { test('request shape, certificates, freshness', () => { expect(profileCreateReq('P', 'B1', ['C1'], ['D1', 'D3'])).toEqual({ data: { type: 'profiles', attributes: { name: 'P', profileType: 'IOS_APP_ADHOC' }, relationships: { bundleId: { data: { type: 'bundleIds', id: 'B1' } }, certificates: { data: [{ type: 'certificates', id: 'C1' }] }, devices: { data: [{ type: 'devices', id: 'D1' }, { type: 'devices', id: 'D3' }] } } } }); const certs = [ { id: 'C1', attributes: { certificateType: 'DISTRIBUTION', expirationDate: '2027-05-01T00:00:00.000+0000' } }, { id: 'C2', attributes: { certificateType: 'IOS_DISTRIBUTION', expirationDate: '2025-01-01T00:00:00.000+0000' } }, { id: 'C3', attributes: { certificateType: 'DEVELOPMENT', expirationDate: '2027-05-01T00:00:00.000+0000' } }]; expect(usableCertificates(certs, new Date('2026-10-11'))).toEqual(['C1']); expect(profileCurrent('ACTIVE', ['D1', 'D3'], ['D1'])).toEqual({ current: true, missing: 0 }); expect(profileCurrent('ACTIVE', ['D1'], ['D1', 'D4'])).toEqual({ current: false, missing: 1 }); expect(profileCurrent('INVALID', ['D1'], ['D1']).current).toBe(false); }); }); describe('apple adhoc runs (mocked App Store Connect)', () => { const route = (c: Call) => { if (c.method === 'GET' && c.url.startsWith('https://api.appstoreconnect.apple.com/v1/devices')) return json(DEVICES); if (c.method === 'POST' && c.url.endsWith('/v1/devices')) { const b = JSON.parse(String(c.body)); if (b.data.attributes.udid === OLD) return json({ errors: [{ status: '409', code: 'ENTITY_ERROR', detail: `A device with number '${OLD}' already exists on this team.` }] }, 409); return json({ data: { type: 'devices', id: 'D9', attributes: { ...b.data.attributes, status: 'ENABLED' } } }, 201); } return json({ errors: [{ status: '400' }] }, 400); }; const files = { '.storebound/devices.csv': CSV }; test('devices --json: every UDID masked', async () => { ascKey(); const calls = fake(route); const out = await captured(() => adhocRun({ loaded: project(), args: parseArgs(['devices', '--platform', 'IOS', '--json']) })); expect(calls.map(c => c.url)).toEqual(['https://api.appstoreconnect.apple.com/v1/devices?limit=200&filter[platform]=IOS']); const j = JSON.parse(out); expect(j.devices[0]).toEqual({ id: 'D1', name: 'Test iphone D1', platform: 'IOS', deviceClass: 'IPHONE', model: 'iPhone 15', status: 'ENABLED', udid: '0000…6677' }); expect(j.usage.length).toBe(3); expect(leaks(out)).toEqual([]); }); test('register --dry: reads, lists masked, sends no POST', async () => { ascKey(); const calls = fake(route); const out = await captured(() => adhocRun({ loaded: project({}, files), args: parseArgs(['register', '--dry']) })); expect(calls.every(c => c.method === 'GET')).toBe(true); expect(out).toContain('4 in the list: 2 new, 1 already registered, 1 disabled'); expect(out).toContain('IPHONE: 1 registered of 100 per membership year'); expect(out).toContain('0000…5F60'); expect(out).toContain('dry run: would register 2 device(s)'); expect(leaks(out)).toEqual([]); }); test('register without a terminal and without --yes: no POST', async () => { ascKey(); noTty(); const calls = fake(route); const out = await captured(() => adhocRun({ loaded: project({}, files), args: parseArgs(['register']) })); expect(calls.some(c => c.method !== 'GET')).toBe(false); expect(out).toContain('re-run with --yes'); expect(leaks(out)).toEqual([]); }); test('register --yes: one POST per new device; an API error echoing the UDID is masked', async () => { ascKey(); const calls = fake(route); let err = ''; const out = await captured(async () => { try { await adhocRun({ loaded: project({}, files), args: parseArgs(['register', '--yes']) }); } catch (e) { err = (e as Error).message; } }); const posts = calls.filter(c => c.method === 'POST'); expect(posts.map(c => JSON.parse(String(c.body)))).toEqual([ { data: { type: 'devices', attributes: { name: 'New phone', udid: NEW, platform: 'IOS' } } }, { data: { type: 'devices', attributes: { name: 'Old pad', udid: OLD, platform: 'IOS' } } }]); expect(out).toContain('registered New phone (0000…5F60)'); expect(out).toContain('Old pad (aaaa…aaab) not registered'); expect(err).toBe('1 of 2 device(s) not registered'); expect(leaks(out + err)).toEqual([]); }, 20_000); // signs an ES256 token and runs two POSTs: over 5 s on a busy CI runner test('register --udid with a bad UDID fails before any call, masked', async () => { ascKey(); const calls = fake(route); await expect(adhocRun({ loaded: project(), args: parseArgs(['register', '--name', 'X', '--udid', NEW + 'zz']) })).rejects.toThrow('--udid 0000…60zz: not a device UDID'); expect(calls.length).toBe(0); }); test('profile --dry plans, profile --yes creates the IOS_APP_ADHOC profile with enabled iOS devices only', async () => { ascKey(); const prof = (c: Call) => { if (c.url.includes('/v1/bundleIds?')) return json({ data: [{ type: 'bundleIds', id: 'B1', attributes: { identifier: PKG, platform: 'IOS', name: 'Example' } }, { type: 'bundleIds', id: 'B2', attributes: { identifier: PKG + '.widget', platform: 'IOS' } }] }); if (c.url.includes('/v1/certificates?')) return json({ data: [{ type: 'certificates', id: 'C1', attributes: { certificateType: 'DISTRIBUTION', expirationDate: '2099-01-01T00:00:00.000+0000' } }] }); if (c.url.includes('/v1/bundleIds/B1/profiles')) return json({ data: [] }); if (c.method === 'POST' && c.url.endsWith('/v1/profiles')) return json({ data: { type: 'profiles', id: 'P1', attributes: { name: 'x', uuid: '00000000-0000-0000-0000-000000000000', profileContent: Buffer.from('profile').toString('base64') } } }, 201); return route(c); }; let calls = fake(prof); const loaded = project({ apple: { bundleId: PKG, teamId: 'ABCDEFGHIJ', adhoc: { profile: 'Example AdHoc' } } } as Partial); const dryOut = await captured(() => adhocRun({ loaded, args: parseArgs(['profile', '--dry']) })); expect(calls.every(c => c.method === 'GET')).toBe(true); expect(calls.map(c => c.url)).toContain('https://api.appstoreconnect.apple.com/v1/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200'); expect(dryOut).toContain('plan: create IOS_APP_ADHOC "Example AdHoc" for com.example.game: 1 devices (1 IPHONE), 1 distribution certificate(s)'); calls = fake(prof); await captured(() => adhocRun({ loaded, args: parseArgs(['profile', '--yes']) })); const post = calls.find(c => c.method === 'POST')!; expect(JSON.parse(String(post.body))).toEqual(profileCreateReq('Example AdHoc', 'B1', ['C1'], ['D1'])); expect(existsSync(join(loaded.root, 'build/adhoc/Example_AdHoc.mobileprovision'))).toBe(true); }); test('options --dry prints the plist and writes nothing; options writes it', async () => { const loaded = project(); const out = await captured(() => adhocRun({ loaded, args: parseArgs(['options', '--dry', '--method', 'ad-hoc']) })); expect(out).toContain('ad-hoc'); expect(existsSync(join(loaded.root, 'build/ExportOptions.plist'))).toBe(false); await captured(() => adhocRun({ loaded, args: parseArgs(['options']) })); expect(existsSync(join(loaded.root, 'build/ExportOptions.plist'))).toBe(true); await expect(adhocRun({ loaded, args: parseArgs(['options', '--method', 'enterprise']) })).rejects.toThrow('--method'); }); test('export --dry prints the exact command; nothing written or run', async () => { const loaded = project({}, { 'build/App.xcarchive/Info.plist': '' }); const archive = join(loaded.root, 'build/App.xcarchive'); const out = await captured(() => adhocRun({ loaded, args: parseArgs(['export', '--archive', archive, '--dry']) })); expect(out).toContain(shellLine(exportArgv(archive, join(loaded.root, 'build/ExportOptions.plist'), join(loaded.root, 'build/adhoc')))); expect(out).toContain('would write build/ExportOptions.plist'); expect(existsSync(join(loaded.root, 'build/ExportOptions.plist'))).toBe(false); expect(existsSync(join(loaded.root, 'build/adhoc'))).toBe(false); }); }); describe('play internal app sharing', () => { const ART = { downloadUrl: 'https://play.google.com/apps/test/com.example.game/placeholder', certificateFingerprint: 'AB:CD:EF', sha256: '' }; test('URL is not under applications/{packageName}; limits; response parsing', () => { expect(shareUrl(PKG, 'bundle')).toBe('https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/internalappsharing/com.example.game/artifacts/bundle?uploadType=media'); expect(shareUrl(PKG, 'apk')).toEndWith('/internalappsharing/com.example.game/artifacts/apk?uploadType=media'); expect(SHARE_HEADERS).toEqual({ 'Content-Type': 'application/octet-stream' }); expect(SHARE_LIMIT).toEqual({ bundle: 10 * 1024 ** 3, apk: 1024 ** 3 }); expect(shareProblems('bundle', 'a.apk', 10)).toEqual(['--aab a.apk: expected an .aab file']); expect(shareProblems('apk', 'a.apk', SHARE_LIMIT.apk + 1)[0]).toContain('above the 1024.0 MB internal app sharing limit'); expect(parseShareArtifact({ ...ART, sha256: 'ABC' })).toEqual({ ...ART, sha256: 'abc' }); expect(() => parseShareArtifact({})).toThrow('no downloadUrl'); }); test('share --dry sends nothing (not even a token request)', async () => { let n = 0; globalThis.fetch = (async () => { n++; return json({}); }) as unknown as typeof fetch; const loaded = project({}, { 'app.aab': 'bundle bytes' }); const out = await captured(() => shareRun({ loaded, args: parseArgs(['--aab', join(loaded.root, 'app.aab'), '--dry', '--json']) })); expect(n).toBe(0); expect(JSON.parse(out)).toMatchObject({ dry: true, packageName: PKG, kind: 'bundle', bytes: 12, sha256: expect.stringMatching(/^[0-9a-f]{64}$/) }); }); test('share without --yes and no terminal: nothing sent', async () => { noTty(); let n = 0; globalThis.fetch = (async () => { n++; return json({}); }) as unknown as typeof fetch; const loaded = project({}, { 'app.apk': 'apk bytes' }); await captured(() => shareRun({ loaded, args: parseArgs(['--apk', join(loaded.root, 'app.apk')]) })); expect(n).toBe(0); }); test('share --yes --json: one POST of the raw bytes, octet-stream, the link printed', async () => { playKey(); const calls = fake(() => json(ART)); const loaded = project({}, { 'app.aab': 'bundle bytes' }); const out = await captured(() => shareRun({ loaded, args: parseArgs(['--aab', join(loaded.root, 'app.aab'), '--yes', '--json']) })); expect(calls.length).toBe(1); expect(calls[0]!.method).toBe('POST'); expect(calls[0]!.url).toBe(shareUrl(PKG, 'bundle')); expect(calls[0]!.headers['Content-Type']).toBe('application/octet-stream'); expect(calls[0]!.headers.Authorization).toBe('Bearer tok'); expect(new TextDecoder().decode(calls[0]!.body as Uint8Array)).toBe('bundle bytes'); expect(JSON.parse(out.slice(out.indexOf('{')))).toEqual(ART); }); });