diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6ef998a8..b8a0d0fef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/build/createContext.ts b/packages/eas-cli/src/build/createContext.ts index 9b2f157852..fb839faab2 100644 --- a/packages/eas-cli/src/build/createContext.ts +++ b/packages/eas-cli/src/build/createContext.ts @@ -42,6 +42,7 @@ export async function createBuildContextAsync({ customBuildConfigMetadata, buildLoggerLevel, freezeCredentials, + refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag, isVerboseLoggingEnabled, whatToTest, env, @@ -65,6 +66,7 @@ export async function createBuildContextAsync({ customBuildConfigMetadata?: CustomBuildConfigMetadata; buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; + refreshAdHocProvisioningProfile?: boolean; isVerboseLoggingEnabled: boolean; whatToTest?: string; env: Record; @@ -90,6 +92,8 @@ export async function createBuildContextAsync({ const requiredPackageManager = resolvePackageManager(projectDir); + const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false; + const credentialsCtx = new CredentialsContext({ projectInfo: { exp, projectId }, nonInteractive, @@ -101,6 +105,7 @@ export async function createBuildContextAsync({ easJsonCliConfig, vcsClient, freezeCredentials, + refreshAdHocProvisioningProfile, }); const devClientProperties = getDevClientEventProperties({ diff --git a/packages/eas-cli/src/build/ios/__tests__/credentials-test.ts b/packages/eas-cli/src/build/ios/__tests__/credentials-test.ts new file mode 100644 index 0000000000..4cfb91d4a8 --- /dev/null +++ b/packages/eas-cli/src/build/ios/__tests__/credentials-test.ts @@ -0,0 +1,40 @@ +import { Platform } from '@expo/eas-build-job'; +import { CredentialsSource } from '@expo/eas-json'; + +import { BuildContext } from '../../context'; +import { ensureIosCredentialsAsync } from '../credentials'; + +describe(ensureIosCredentialsAsync, () => { + it('errors when refresh is enabled with local credentials source', async () => { + const buildCtx = { + buildProfile: { + credentialsSource: CredentialsSource.LOCAL, + simulator: false, + withoutCredentials: false, + }, + credentialsCtx: { + refreshAdHocProvisioningProfile: true, + }, + } as BuildContext; + + await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow( + '--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.' + ); + }); + + it('does not reject refresh when credentials source is remote', async () => { + const buildCtx = { + buildProfile: { + credentialsSource: CredentialsSource.REMOTE, + simulator: true, + withoutCredentials: false, + distribution: 'internal', + }, + credentialsCtx: { + refreshAdHocProvisioningProfile: true, + }, + } as BuildContext; + + await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined(); + }); +}); diff --git a/packages/eas-cli/src/build/ios/credentials.ts b/packages/eas-cli/src/build/ios/credentials.ts index 7fe36007ac..d80310c570 100644 --- a/packages/eas-cli/src/build/ios/credentials.ts +++ b/packages/eas-cli/src/build/ios/credentials.ts @@ -17,6 +17,16 @@ export async function ensureIosCredentialsAsync( return; } + const { credentialsSource } = buildCtx.buildProfile; + if ( + buildCtx.credentialsCtx.refreshAdHocProvisioningProfile && + credentialsSource === 'local' + ) { + throw new Error( + '--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.' + ); + } + const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, { app: await getAppFromContextAsync(buildCtx.credentialsCtx), targets, @@ -24,8 +34,6 @@ export async function ensureIosCredentialsAsync( enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning, }); - const { credentialsSource } = buildCtx.buildProfile; - logCredentialsSource(credentialsSource, Platform.IOS); return { credentials: await provider.getCredentialsAsync(credentialsSource), diff --git a/packages/eas-cli/src/build/runBuildAndSubmit.ts b/packages/eas-cli/src/build/runBuildAndSubmit.ts index c67ebfa5b4..28ab4ceea3 100644 --- a/packages/eas-cli/src/build/runBuildAndSubmit.ts +++ b/packages/eas-cli/src/build/runBuildAndSubmit.ts @@ -103,6 +103,7 @@ export interface BuildFlags { message?: string; buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; + refreshAdHocProvisioningProfile?: boolean; isVerboseLoggingEnabled?: boolean; whatToTest?: string; simulator?: SimulatorRunTarget; @@ -413,6 +414,7 @@ async function prepareAndStartBuildAsync({ customBuildConfigMetadata, buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined), freezeCredentials: flags.freezeCredentials, + refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile, isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false, whatToTest: flags.whatToTest, env, diff --git a/packages/eas-cli/src/build/types.ts b/packages/eas-cli/src/build/types.ts index cd60379cff..65c00cbcc7 100644 --- a/packages/eas-cli/src/build/types.ts +++ b/packages/eas-cli/src/build/types.ts @@ -35,4 +35,5 @@ export interface BuildFlags { message?: string; buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; + refreshAdHocProvisioningProfile?: boolean; } diff --git a/packages/eas-cli/src/commands/build/index.ts b/packages/eas-cli/src/commands/build/index.ts index a40104b5bf..77460b0d0a 100644 --- a/packages/eas-cli/src/commands/build/index.ts +++ b/packages/eas-cli/src/commands/build/index.ts @@ -40,6 +40,7 @@ interface RawBuildFlags { message?: string; 'build-logger-level'?: LoggerLevel; 'freeze-credentials': boolean; + 'refresh-ad-hoc-provisioning-profile': boolean; 'verbose-logs'?: boolean; 'what-to-test'?: string; } @@ -121,6 +122,11 @@ export default class Build extends EasCommand { default: false, description: 'Prevent the build from updating credentials in non-interactive mode', }), + 'refresh-ad-hoc-provisioning-profile': Flags.boolean({ + default: false, + description: + 'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials', + }), 'verbose-logs': Flags.boolean({ default: false, description: 'Use verbose logs for the build process', @@ -188,6 +194,20 @@ export default class Build extends EasCommand { flags: RawBuildFlags ): Omit & { requestedPlatform?: RequestedPlatform } { const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (flags['refresh-ad-hoc-provisioning-profile']) { + if (!nonInteractive) { + Errors.error( + '--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.', + { exit: 1 } + ); + } + if (flags['freeze-credentials']) { + Errors.error( + 'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.', + { exit: 1 } + ); + } + } if (!flags.local && flags.output) { Errors.error('--output is allowed only for local builds', { exit: 1 }); } @@ -249,6 +269,7 @@ export default class Build extends EasCommand { message, buildLoggerLevel: flags['build-logger-level'], freezeCredentials: flags['freeze-credentials'], + refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'], isVerboseLoggingEnabled: flags['verbose-logs'], whatToTest: flags['what-to-test'], }; diff --git a/packages/eas-cli/src/credentials/context.ts b/packages/eas-cli/src/credentials/context.ts index 0f7c092918..8762521e7a 100644 --- a/packages/eas-cli/src/credentials/context.ts +++ b/packages/eas-cli/src/credentials/context.ts @@ -27,6 +27,7 @@ export class CredentialsContext { public readonly nonInteractive: boolean; public readonly autoAcceptCredentialReuse: boolean; public readonly freezeCredentials: boolean = false; + public readonly refreshAdHocProvisioningProfile: boolean = false; public readonly projectDir: string; public readonly user: Actor; public readonly graphqlClient: ExpoGraphqlClient; @@ -52,6 +53,7 @@ export class CredentialsContext { vcsClient: Client; freezeCredentials?: boolean; autoAcceptCredentialReuse?: boolean; + refreshAdHocProvisioningProfile?: boolean; env?: Env; } ) { @@ -65,6 +67,7 @@ export class CredentialsContext { this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false; this.projectInfo = options.projectInfo; this.freezeCredentials = options.freezeCredentials ?? false; + this.refreshAdHocProvisioningProfile = options.refreshAdHocProvisioningProfile ?? false; this.usesBroadcastPushNotifications = options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false; } diff --git a/packages/eas-cli/src/credentials/ios/actions/DeviceUtils.ts b/packages/eas-cli/src/credentials/ios/actions/DeviceUtils.ts index 4c0749f3b5..3697709e79 100644 --- a/packages/eas-cli/src/credentials/ios/actions/DeviceUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/DeviceUtils.ts @@ -1,6 +1,37 @@ import { promptAsync } from '../.././../prompts'; -import { AppleDevice, AppleDeviceFragment } from '../../../graphql/generated'; +import { AppleDevice, AppleDeviceClass, AppleDeviceFragment } from '../../../graphql/generated'; import { APPLE_DEVICE_CLASS_LABELS } from '../../../graphql/types/credentials/AppleDevice'; +import { ApplePlatform } from '../appstore/constants'; + +export function filterDevicesForApplePlatform( + devices: AppleDeviceFragment[], + applePlatform: ApplePlatform +): AppleDeviceFragment[] { + if (applePlatform === ApplePlatform.TV_OS) { + throw new Error('Filtering for tvOS is not supported yet'); + } + + if (applePlatform === ApplePlatform.VISION_OS) { + throw new Error('Filtering for visionOS is not supported yet'); + } + + return devices.filter(device => isDeviceCompatibleWithApplePlatform(device, applePlatform)); +} + +function isDeviceCompatibleWithApplePlatform( + device: AppleDeviceFragment, + applePlatform: Exclude +): boolean { + switch (applePlatform) { + case ApplePlatform.IOS: + return ( + device.deviceClass === AppleDeviceClass.Iphone || + device.deviceClass === AppleDeviceClass.Ipad + ); + case ApplePlatform.MAC_OS: + return device.deviceClass === AppleDeviceClass.Mac; + } +} export async function chooseDevicesAsync( allDevices: AppleDeviceFragment[], diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts index b1da7ef72d..d8d192e0bc 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts @@ -6,7 +6,11 @@ import nullthrows from 'nullthrows'; import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils'; import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils'; -import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils'; +import { + chooseDevicesAsync, + filterDevicesForApplePlatform, + formatDeviceLabel, +} from './DeviceUtils'; import { SetUpDistributionCertificate } from './SetUpDistributionCertificate'; import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action'; import { @@ -18,6 +22,8 @@ import { IosAppBuildCredentialsFragment, IosDistributionType, } from '../../../graphql/generated'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery'; import Log from '../../../log'; import { getApplePlatformFromTarget } from '../../../project/ios/target'; import { @@ -29,9 +35,13 @@ import { import differenceBy from '../../../utils/expodash/differenceBy'; import { CredentialsContext } from '../../context'; import { MissingCredentialsNonInteractiveError } from '../../errors'; +import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; +import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes'; import { ProvisioningProfile } from '../appstore/Credentials.types'; import { ApplePlatform } from '../appstore/constants'; +import { hasAscEnvVars } from '../appstore/resolveCredentials'; +import { MinimalAscApiKey } from '../credentials'; import { Target } from '../types'; import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile'; @@ -51,11 +61,23 @@ export class SetUpAdhocProvisioningProfile { async runAsync(ctx: CredentialsContext): Promise { const { app } = this.options; + + if (ctx.refreshAdHocProvisioningProfile && ctx.freezeCredentials) { + throw new Error( + 'Cannot refresh ad-hoc provisioning profile when credentials are frozen. Remove --freeze-credentials or --refresh-ad-hoc-provisioning-profile.' + ); + } + const distCert = await new SetUpDistributionCertificate( app, IosDistributionType.AdHoc ).runAsync(ctx); + if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) { + await this.ensureAppStoreAuthenticatedForAdhocRefreshAsync(ctx, app); + return await this.runWithDistributionCertificateAsync(ctx, distCert); + } + const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx); if (ctx.nonInteractive) { @@ -111,6 +133,11 @@ export class SetUpAdhocProvisioningProfile { appleTeam ); if (registeredAppleDevices.length === 0) { + if (ctx.nonInteractive) { + throw new Error( + 'No devices are registered for this Apple team. Register devices with eas device:create first.' + ); + } const shouldRegisterDevices = await confirmAsync({ message: `You don't have any registered devices yet. Would you like to register them now?`, initial: true, @@ -127,13 +154,13 @@ export class SetUpAdhocProvisioningProfile { const provisionedDeviceIdentifiers = ( currentBuildCredentials?.provisioningProfile?.appleDevices ?? [] ).map(i => i.identifier); - const chosenDevices = await chooseDevicesAsync( - registeredAppleDevices, - provisionedDeviceIdentifiers - ); + const applePlatform = getApplePlatformFromTarget(target); + const chosenDevices = + ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile + ? filterDevicesForApplePlatform(registeredAppleDevices, applePlatform) + : await chooseDevicesAsync(registeredAppleDevices, provisionedDeviceIdentifiers); // 4. Reuse or create the profile on Apple Developer Portal - const applePlatform = getApplePlatformFromTarget(target); const profileType = applePlatform === ApplePlatform.TV_OS ? ProfileType.TVOS_APP_ADHOC @@ -187,21 +214,25 @@ export class SetUpAdhocProvisioningProfile { Log.log( 'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet' ); - const shouldContinue = await selectAsync( - 'Do you want to continue without provisioning these devices?', - [ - { - title: 'Yes', - value: true, - }, - { - title: 'No (EAS CLI will exit)', - value: false, - }, - ] - ); - if (!shouldContinue) { - Errors.exit(1); + if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) { + // Continue without prompting in non-interactive refresh mode. + } else { + const shouldContinue = await selectAsync( + 'Do you want to continue without provisioning these devices?', + [ + { + title: 'Yes', + value: true, + }, + { + title: 'No (EAS CLI will exit)', + value: false, + }, + ] + ); + if (!shouldContinue) { + Errors.exit(1); + } } } @@ -371,6 +402,67 @@ export class SetUpAdhocProvisioningProfile { } } } + + private async ensureAppStoreAuthenticatedForAdhocRefreshAsync( + ctx: CredentialsContext, + app: AppLookupParams + ): Promise { + if (hasAscEnvVars()) { + await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY }); + return; + } + + const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({ + graphqlClient: ctx.graphqlClient, + app, + }); + if (!resolvedKey) { + throw new Error( + 'No App Store Connect API Key found for ad-hoc provisioning profile refresh. In non-interactive mode, provide one via:\n' + + ' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' + + ' - EAS credentials service: configure an App Store Connect API Key for submissions on this app' + ); + } + + await ctx.appStore.ensureAuthenticatedAsync({ + mode: AuthenticationMode.API_KEY, + ascApiKey: resolvedKey.ascApiKey, + teamId: resolvedKey.teamId, + teamName: resolvedKey.teamName, + // Provide a non-enterprise team type to avoid interactive team-type resolution. + // Ad-hoc profile handling below uses explicit ProfileType and does not branch on team.inHouse. + teamType: AppleTeamType.COMPANY_OR_ORGANIZATION, + }); + } +} + +async function resolveAscApiKeyForAppCredentialsAsync({ + graphqlClient, + app, +}: { + graphqlClient: ExpoGraphqlClient; + app: AppLookupParams; +}): Promise<{ + ascApiKey: MinimalAscApiKey; + teamId?: string; + teamName?: string; +} | null> { + const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app); + if (!ascKeyFragment) { + return null; + } + + Log.log('Using App Store Connect API Key from EAS credentials service.'); + const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id); + return { + ascApiKey: { + keyP8: fullKey.keyP8, + keyId: fullKey.keyIdentifier, + issuerId: fullKey.issuerIdentifier, + }, + teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier, + teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined, + }; } export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean { diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpTargetBuildCredentials.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpTargetBuildCredentials.ts index df62dc44d7..da6f32d74b 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpTargetBuildCredentials.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpTargetBuildCredentials.ts @@ -54,6 +54,18 @@ export class SetUpTargetBuildCredentials { ctx: CredentialsContext ): Promise { const { app, distribution, enterpriseProvisioning, target } = this.options; + if (ctx.refreshAdHocProvisioningProfile) { + if (distribution !== 'internal') { + throw new Error( + '--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.' + ); + } + if (enterpriseProvisioning === 'universal') { + throw new Error( + '--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.' + ); + } + } if (distribution === 'internal') { if (enterpriseProvisioning === 'adhoc') { return await new SetUpAdhocProvisioningProfile({ app, target }).runAsync(ctx); diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/DeviceUtils-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/DeviceUtils-test.ts index 7d8b672444..550be775d8 100644 --- a/packages/eas-cli/src/credentials/ios/actions/__tests__/DeviceUtils-test.ts +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/DeviceUtils-test.ts @@ -1,7 +1,65 @@ import { v4 as uuidv4 } from 'uuid'; import { AppleDeviceClass, AppleDeviceFragment } from '../../../../graphql/generated'; -import { formatDeviceLabel } from '../DeviceUtils'; +import { ApplePlatform } from '../../appstore/constants'; +import { filterDevicesForApplePlatform, formatDeviceLabel } from '../DeviceUtils'; + +function createDevice( + overrides: Partial & Pick +): AppleDeviceFragment { + return { + __typename: 'AppleDevice', + id: uuidv4(), + name: 'device', + model: null, + deviceClass: null, + createdAt: new Date().toISOString(), + ...overrides, + } as AppleDeviceFragment; +} + +describe(filterDevicesForApplePlatform, () => { + const iphone = createDevice({ + identifier: 'iphone', + deviceClass: AppleDeviceClass.Iphone, + }); + const ipad = createDevice({ + identifier: 'ipad', + deviceClass: AppleDeviceClass.Ipad, + }); + const mac = createDevice({ + identifier: 'mac', + deviceClass: AppleDeviceClass.Mac, + }); + const appleTv = createDevice({ + identifier: 'appletv', + model: 'AppleTV6,3', + deviceClass: AppleDeviceClass.Unknown, + }); + const unknown = createDevice({ + identifier: 'unknown', + deviceClass: AppleDeviceClass.Unknown, + }); + const noClass = createDevice({ + identifier: 'noclass', + deviceClass: null, + }); + + it('keeps only iPhone and iPad devices for iOS targets', () => { + expect( + filterDevicesForApplePlatform( + [iphone, ipad, mac, appleTv, unknown, noClass], + ApplePlatform.IOS + ) + ).toEqual([iphone, ipad]); + }); + + it('throws for tvOS targets', () => { + expect(() => + filterDevicesForApplePlatform([iphone, ipad, mac, appleTv], ApplePlatform.TV_OS) + ).toThrow('Filtering for tvOS is not supported yet'); + }); +}); describe(formatDeviceLabel, () => { it('returns createdAt clause', async () => { diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpAdhocProvisioningProfile-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpAdhocProvisioningProfile-test.ts index a446b257f1..0352ddbbe1 100644 --- a/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpAdhocProvisioningProfile-test.ts +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpAdhocProvisioningProfile-test.ts @@ -23,17 +23,37 @@ import { ApplePlatform } from '../../appstore/constants'; import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils'; import { chooseDevicesAsync } from '../DeviceUtils'; import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile'; +import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient'; +import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes'; +import { hasAscEnvVars } from '../../appstore/resolveCredentials'; +import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery'; jest.mock('../BuildCredentialsUtils'); jest.mock('../../../context'); -jest.mock('../../../ios/api/GraphqlClient'); +jest.mock('../../../ios/api/GraphqlClient', () => ({ + ...jest.requireActual('../../../ios/api/GraphqlClient'), + getAscApiKeyForAppSubmissionsAsync: jest.fn(), +})); +jest.mock('../../appstore/resolveCredentials', () => ({ + hasAscEnvVars: jest.fn(), +})); +jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({ + AppStoreConnectApiKeyQuery: { + getByIdAsync: jest.fn(), + }, +})); jest.mock('../DeviceUtils', () => { return { __esModule: true, + ...jest.requireActual('../DeviceUtils'), chooseDevicesAsync: jest.fn(), formatDeviceLabel: jest.requireActual('../DeviceUtils').formatDeviceLabel, }; }); +jest.mock('../SetUpDistributionCertificate', () => ({ + SetUpDistributionCertificate: jest.fn(), +})); +import { SetUpDistributionCertificate } from '../SetUpDistributionCertificate'; jest.mock('../../../../project/ios/target'); jest.mock('../../../../prompts'); @@ -148,6 +168,231 @@ describe('runWithDistributionCertificateAsync', () => { }); }); +describe('runAsync', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.mocked(SetUpDistributionCertificate).mockImplementation( + () => + ({ + runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment), + }) as any + ); + }); + + const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({ + app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' }, + target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} }, + }); + + it('skips non-interactive reuse when refresh is enabled', async () => { + const { ctx } = setUpTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true }); + const areBuildCredentialsSetupAsyncSpy = jest + .spyOn(SetUpAdhocProvisioningProfile.prototype as any, 'areBuildCredentialsSetupAsync') + .mockResolvedValue(true); + const ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy = jest + .spyOn( + SetUpAdhocProvisioningProfile.prototype as any, + 'ensureAppStoreAuthenticatedForAdhocRefreshAsync' + ) + .mockResolvedValue(undefined); + jest + .spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync') + .mockResolvedValue({} as IosAppBuildCredentialsFragment); + + await setUpAdhocProvisioningProfile.runAsync(ctx); + + expect(areBuildCredentialsSetupAsyncSpy).not.toHaveBeenCalled(); + expect(ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy).toHaveBeenCalledWith(ctx, { + account: {} as Account, + projectName: 'projName', + bundleIdentifier: 'bundleId', + }); + expect( + SetUpAdhocProvisioningProfile.prototype.runWithDistributionCertificateAsync + ).toHaveBeenCalled(); + expect(getBuildCredentialsAsync).not.toHaveBeenCalled(); + }); + + it('fails when refresh is enabled and credentials are frozen', async () => { + const { ctx } = setUpTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true }); + Object.defineProperty(ctx, 'freezeCredentials', { value: true }); + + await expect(setUpAdhocProvisioningProfile.runAsync(ctx)).rejects.toThrow( + 'Cannot refresh ad-hoc provisioning profile when credentials are frozen' + ); + }); +}); + +describe('refresh ad-hoc provisioning profile', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS); + jest.mocked(SetUpDistributionCertificate).mockImplementation( + () => + ({ + runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment), + }) as any + ); + jest.mocked(hasAscEnvVars).mockReturnValue(false); + jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue(null); + }); + + const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({ + app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' }, + target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} }, + }); + + it('provisions all registered devices without prompting in refresh mode', async () => { + const { ctx, distCert } = setUpRefreshTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true }); + + await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert); + + expect(chooseDevicesAsync).not.toHaveBeenCalled(); + expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith( + ['id1', 'id2', 'id3'], + 'bundleId', + distCert.serialNumber, + expect.anything() + ); + }); + + it('filters devices by target platform only in refresh mode', async () => { + const { ctx, distCert } = setUpRefreshTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true }); + ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([ + { identifier: 'id1', model: 'iPhone17,1', deviceClass: AppleDeviceClass.Iphone }, + { identifier: 'id2', model: 'AppleTV6,3', deviceClass: AppleDeviceClass.Unknown }, + { identifier: 'id3', deviceClass: AppleDeviceClass.Mac }, + ]); + + await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert); + + expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith( + ['id1'], + 'bundleId', + distCert.serialNumber, + expect.anything() + ); + expect(chooseDevicesAsync).not.toHaveBeenCalled(); + }); + + it('continues without prompting when some devices are not provisioned in refresh mode', async () => { + const { ctx, distCert } = setUpRefreshTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true }); + jest.mocked(getBuildCredentialsAsync).mockResolvedValue({ + provisioningProfile: { + appleTeam: { appleTeamIdentifier: 'team' }, + appleDevices: [{ identifier: 'id1' }], + developerPortalIdentifier: 'provisioningProfileId', + }, + } as IosAppBuildCredentialsFragment); + ctx.ios.updateProvisioningProfileAsync = jest.fn().mockResolvedValue({ + appleTeam: { appleTeamIdentifier: 'team' }, + appleDevices: [{ identifier: 'id1' }], + developerPortalIdentifier: 'provisioningProfileId', + }); + + await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert); + + expect(selectAsync).not.toHaveBeenCalled(); + expect(assignBuildCredentialsAsync).toHaveBeenCalled(); + }); + + it('errors when no devices are registered in non-interactive mode', async () => { + const { ctx, distCert } = setUpRefreshTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([]); + + await expect( + setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert) + ).rejects.toThrow( + 'No devices are registered for this Apple team. Register devices with eas device:create first.' + ); + }); + + describe('ensureAppStoreAuthenticatedForAdhocRefreshAsync', () => { + it('authenticates with ASC environment variables when present', async () => { + const { ctx } = setUpRefreshTest(); + jest.mocked(hasAscEnvVars).mockReturnValue(true); + jest + .spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync') + .mockResolvedValue({} as IosAppBuildCredentialsFragment); + + await setUpAdhocProvisioningProfile.runAsync(ctx); + + expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({ + mode: AuthenticationMode.API_KEY, + }); + expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled(); + }); + + it('authenticates with the stored submissions ASC API key when env vars are absent', async () => { + const { ctx } = setUpRefreshTest(); + jest.mocked(hasAscEnvVars).mockReturnValue(false); + jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue({ + id: 'asc-key-id', + appleTeam: { + appleTeamIdentifier: 'TEAM123', + appleTeamName: 'Team Name', + }, + } as any); + jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({ + keyP8: 'key-p8', + keyIdentifier: 'key-id', + issuerIdentifier: 'issuer-id', + } as any); + jest + .spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync') + .mockResolvedValue({} as IosAppBuildCredentialsFragment); + + await setUpAdhocProvisioningProfile.runAsync(ctx); + + expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({ + mode: AuthenticationMode.API_KEY, + ascApiKey: { + keyP8: 'key-p8', + keyId: 'key-id', + issuerId: 'issuer-id', + }, + teamId: 'TEAM123', + teamName: 'Team Name', + teamType: AppleTeamType.COMPANY_OR_ORGANIZATION, + }); + }); + }); +}); + +function setUpRefreshTest(): { + ctx: CredentialsContext; + distCert: AppleDistributionCertificateFragment; +} { + const { ctx, distCert } = setUpTest(); + Object.defineProperty(ctx, 'nonInteractive', { value: true }); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true }); + distCert.appleTeam = { + appleTeamIdentifier: 'team', + } as AppleDistributionCertificateFragment['appleTeam']; + distCert.serialNumber = 'serial-number'; + ctx.appStore.ensureAuthenticatedAsync = jest.fn().mockResolvedValue({}); + jest.mocked(getBuildCredentialsAsync).mockResolvedValue(null); + ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([ + { identifier: 'id1', deviceClass: AppleDeviceClass.Iphone }, + { identifier: 'id2', deviceClass: AppleDeviceClass.Ipad }, + { identifier: 'id3', deviceClass: AppleDeviceClass.Iphone }, + ] as AppleDeviceFragment[]); + return { ctx, distCert }; +} + function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCertificateFragment } { const ctx = jest.mocked( new CredentialsContext( @@ -177,8 +422,7 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert { identifier: 'id2', name: 'Device 2', deviceClass: AppleDeviceClass.Iphone }, { identifier: 'id3', name: 'Device 3', deviceClass: AppleDeviceClass.Mac }, ] as AppleDevice[]); - // @ts-expect-error - jest.mocked(getApplePlatformFromTarget).mockResolvedValue(ApplePlatform.IOS); + jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS); Object.defineProperty(ctx, 'appStore', { value: jest.mock('../../appstore/AppStoreApi') }); ctx.appStore.createOrReuseAdhocProvisioningProfileAsync = jest.fn().mockResolvedValue({ provisioningProfileId: 'provisioningProfileId', @@ -186,6 +430,11 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert ctx.ios.createOrGetExistingAppleAppIdentifierAsync = jest .fn() .mockResolvedValue({} as AppleAppIdentifierFragment); + ctx.ios.createProvisioningProfileAsync = jest.fn().mockResolvedValue({ + appleTeam: { appleTeamIdentifier: 'team' }, + appleDevices: [{ identifier: 'id1' }, { identifier: 'id2' }, { identifier: 'id3' }], + developerPortalIdentifier: 'provisioningProfileId', + }); jest.mocked(assignBuildCredentialsAsync).mockResolvedValue({} as IosAppBuildCredentialsFragment); const distCert = {} as AppleDistributionCertificateFragment; return { ctx, distCert }; diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpTargetBuildCredentials-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpTargetBuildCredentials-test.ts new file mode 100644 index 0000000000..1634c869f3 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpTargetBuildCredentials-test.ts @@ -0,0 +1,110 @@ +import { Env } from '@expo/eas-build-job'; +import { EasJson } from '@expo/eas-json'; + +import { Analytics } from '../../../../analytics/AnalyticsManager'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { Account, IosAppBuildCredentialsFragment } from '../../../../graphql/generated'; +import { Actor } from '../../../../user/User'; +import { Client } from '../../../../vcs/vcs'; +import { CredentialsContext, CredentialsContextProjectInfo } from '../../../context'; +import { SetUpAdhocProvisioningProfile } from '../SetUpAdhocProvisioningProfile'; +import { SetUpInternalProvisioningProfile } from '../SetUpInternalProvisioningProfile'; +import { SetUpTargetBuildCredentials } from '../SetUpTargetBuildCredentials'; + +jest.mock('../SetUpAdhocProvisioningProfile', () => ({ + SetUpAdhocProvisioningProfile: jest.fn(), +})); +jest.mock('../SetUpInternalProvisioningProfile', () => ({ + SetUpInternalProvisioningProfile: jest.fn(), +})); +jest.mock('../../../context'); + +describe(SetUpTargetBuildCredentials, () => { + const app = { + account: { name: 'account' } as Account, + projectName: 'projName', + bundleIdentifier: 'bundleId', + }; + const target = { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(SetUpAdhocProvisioningProfile).mockImplementation( + () => + ({ + runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment), + }) as any + ); + jest.mocked(SetUpInternalProvisioningProfile).mockImplementation( + () => + ({ + runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment), + }) as any + ); + }); + + function createCtx(overrides: Partial = {}): CredentialsContext { + const ctx = jest.mocked( + new CredentialsContext( + {} as { + projectInfo: CredentialsContextProjectInfo | null; + easJsonCliConfig?: EasJson['cli']; + nonInteractive: boolean; + projectDir: string; + user: Actor; + graphqlClient: ExpoGraphqlClient; + analytics: Analytics; + env?: Env; + vcsClient: Client; + } + ) + ); + Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { + value: true, + writable: true, + }); + return Object.assign(ctx, overrides); + } + + it('errors when refresh is enabled for store distribution builds', async () => { + const action = new SetUpTargetBuildCredentials({ + app, + distribution: 'store', + entitlements: {}, + target, + }); + + await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow( + '--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.' + ); + }); + + it('errors when refresh is enabled for universal enterprise internal builds', async () => { + const action = new SetUpTargetBuildCredentials({ + app, + distribution: 'internal', + enterpriseProvisioning: 'universal', + entitlements: {}, + target, + }); + + await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow( + '--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.' + ); + }); + + it('delegates to internal provisioning setup when refresh is enabled for internal ad-hoc builds', async () => { + const action = new SetUpTargetBuildCredentials({ + app, + distribution: 'internal', + entitlements: {}, + target, + }); + const ctx = createCtx(); + + await action.setupBuildCredentialsAsync(ctx); + + expect(SetUpInternalProvisioningProfile).toHaveBeenCalledWith({ app, target }); + expect(SetUpAdhocProvisioningProfile).not.toHaveBeenCalled(); + }); +});