diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f0a03c2a6..a63f139e62 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] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials (for example, the submission key) to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index a36e83df9d..b88a4aa450 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -5,7 +5,9 @@ import path from 'path'; import { UserRole } from '@expo/apple-utils'; import { formatAppleTeam } from './AppleTeamFormatting'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated'; +import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery'; import Log, { learnMore } from '../../../log'; import { confirmAsync, promptAsync, selectAsync } from '../../../prompts'; import { fromNow } from '../../../utils/date'; @@ -14,7 +16,11 @@ import { getCredentialsFromUserAsync, shouldAutoGenerateCredentialsAsync, } from '../../utils/promptForCredentials'; +import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient'; +import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { AscApiKey } from '../appstore/Credentials.types'; +import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes'; +import { hasAscEnvVars } from '../appstore/resolveCredentials'; import { AscApiKeyPath, MinimalAscApiKey, @@ -277,3 +283,77 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`); return line; } + +export 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; + } + 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, + }; +} + +/** + * Best-effort, side-effecting helper that ensures `ctx.appStore` is authenticated + * with an App Store Connect API key when running in non-interactive mode. + * + * Returns true if `ctx.appStore.authCtx` is set after the call, false otherwise. + * Never throws. + */ +export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync( + ctx: CredentialsContext, + app: AppLookupParams, + teamType: AppleTeamType +): Promise { + if (ctx.appStore.authCtx) { + return true; + } + try { + if (hasAscEnvVars()) { + await ctx.appStore.ensureAuthenticatedAsync({ + mode: AuthenticationMode.API_KEY, + teamType, + }); + return !!ctx.appStore.authCtx; + } + const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({ + graphqlClient: ctx.graphqlClient, + app, + }); + if (!resolvedKey) { + return false; + } + Log.log('Using App Store Connect API Key from EAS credentials service.'); + await ctx.appStore.ensureAuthenticatedAsync({ + mode: AuthenticationMode.API_KEY, + ascApiKey: resolvedKey.ascApiKey, + teamId: resolvedKey.teamId, + teamName: resolvedKey.teamName, + teamType, + }); + return !!ctx.appStore.authCtx; + } catch (err: any) { + Log.warn( + `Failed to authenticate with the App Store Connect API key from EAS credentials service: ${err.message ?? err}` + ); + return false; + } +} diff --git a/packages/eas-cli/src/credentials/ios/actions/ConfigureProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/ConfigureProvisioningProfile.ts index 3aacc8e941..62a89d9c22 100644 --- a/packages/eas-cli/src/credentials/ios/actions/ConfigureProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/ConfigureProvisioningProfile.ts @@ -16,7 +16,7 @@ import { import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types'; -import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes'; +import { AuthCtx } from '../appstore/authenticateTypes'; import { Target } from '../types'; export class ConfigureProvisioningProfile { @@ -34,12 +34,9 @@ export class ConfigureProvisioningProfile { throw new ForbidCredentialModificationError( 'Remove the --freeze-credentials flag to configure a Provisioning Profile.' ); - } else if ( - ctx.nonInteractive && - ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY - ) { + } else if (ctx.nonInteractive && !ctx.appStore.authCtx) { throw new InsufficientAuthenticationNonInteractiveError( - `In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore( + `In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for this app on EAS. ${learnMore( 'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team' )}` ); diff --git a/packages/eas-cli/src/credentials/ios/actions/CreateProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/CreateProvisioningProfile.ts index 048f9f36ea..67c1f1c9b7 100644 --- a/packages/eas-cli/src/credentials/ios/actions/CreateProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/CreateProvisioningProfile.ts @@ -14,7 +14,7 @@ import { askForUserProvidedAsync } from '../../utils/promptForCredentials'; import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { ProvisioningProfile } from '../appstore/Credentials.types'; -import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes'; +import { AuthCtx } from '../appstore/authenticateTypes'; import { provisioningProfileSchema } from '../credentials'; import { Target } from '../types'; @@ -30,12 +30,9 @@ export class CreateProvisioningProfile { throw new ForbidCredentialModificationError( 'Run this command again without the --freeze-credentials flag in order to generate a new Provisioning Profile.' ); - } else if ( - ctx.nonInteractive && - ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY - ) { + } else if (ctx.nonInteractive && !ctx.appStore.authCtx) { throw new InsufficientAuthenticationNonInteractiveError( - `In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore( + `In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for this app on EAS. ${learnMore( 'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team' )}` ); diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts index d8d192e0bc..20c5ae4a42 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts @@ -11,6 +11,7 @@ import { filterDevicesForApplePlatform, formatDeviceLabel, } from './DeviceUtils'; +import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils'; import { SetUpDistributionCertificate } from './SetUpDistributionCertificate'; import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action'; import { @@ -22,8 +23,6 @@ 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 { @@ -35,13 +34,11 @@ 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'; @@ -424,6 +421,7 @@ export class SetUpAdhocProvisioningProfile { ); } + Log.log('Using App Store Connect API Key from EAS credentials service.'); await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY, ascApiKey: resolvedKey.ascApiKey, @@ -436,35 +434,6 @@ export class SetUpAdhocProvisioningProfile { } } -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 { const setA = new Set(udidsA); const setB = new Set(udidsB); diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts index e5d68bcdc7..cca9f5ec4b 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts @@ -1,5 +1,6 @@ import nullthrows from 'nullthrows'; +import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils'; import { assignBuildCredentialsAsync, getBuildCredentialsAsync, @@ -15,7 +16,7 @@ import { IosAppBuildCredentialsFragment, IosDistributionType, } from '../../../graphql/generated'; -import { learnMore } from '../../../log'; +import Log, { learnMore } from '../../../log'; import { getApplePlatformFromTarget } from '../../../project/ios/target'; import { confirmAsync } from '../../../prompts'; import { CredentialsContext } from '../../context'; @@ -25,7 +26,8 @@ import { } from '../../errors'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types'; -import { AuthenticationMode } from '../appstore/authenticateTypes'; +import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials'; +import { AppleTeamType } from '../appstore/authenticateTypes'; import { Target } from '../types'; import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile'; @@ -102,8 +104,28 @@ export class SetUpProvisioningProfile { this.app, this.distributionType ).runAsync(ctx); + if (ctx.nonInteractive && !ctx.appStore.authCtx) { + await tryAuthenticateAppStoreWithEasAscApiKeyAsync( + ctx, + this.app, + this.resolveTeamTypeForAuthentication() + ); + } - const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx); + let areBuildCredentialsSetup: boolean; + try { + areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx); + } catch (error: any) { + if (ctx.nonInteractive) { + Log.warn( + 'Skipping Provisioning Profile validation on Apple servers due to an unexpected validation error. Continuing with local validation result.' + ); + Log.debug('Provisioning profile validation on Apple servers failed:', error); + areBuildCredentialsSetup = true; + } else { + throw error; + } + } if (areBuildCredentialsSetup) { return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType)); } @@ -111,12 +133,11 @@ export class SetUpProvisioningProfile { throw new ForbidCredentialModificationError( 'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.' ); - } else if ( - ctx.nonInteractive && - ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY - ) { + } + + if (ctx.nonInteractive && !ctx.appStore.authCtx) { throw new InsufficientAuthenticationNonInteractiveError( - `In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore( + `In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for this app on EAS. ${learnMore( 'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team' )}` ); @@ -167,6 +188,24 @@ export class SetUpProvisioningProfile { return updatedProfile; } + /** + * The team type determines `team.inHouse`, which in turn selects the Apple profile + * type used for every subsequent profile lookup and creation (IOS_APP_INHOUSE for + * enterprise vs IOS_APP_STORE otherwise). We derive it from the distribution + * type, which is exactly what the requested operation needs: enterprise + * builds require an in-house team, other distribution types don't. + * A genuine team/distribution mismatch is rejected by Apple regardless of this value. + */ + private getDerivedTeamTypeForAuthentication(): AppleTeamType { + return this.distributionType === IosDistributionType.Enterprise + ? AppleTeamType.IN_HOUSE + : AppleTeamType.COMPANY_OR_ORGANIZATION; + } + + private resolveTeamTypeForAuthentication(): AppleTeamType { + return resolveAppleTeamTypeFromEnvironment() ?? this.getDerivedTeamTypeForAuthentication(); + } + private getCurrentProfileStoreInfo( profiles: ProvisioningProfileStoreInfo[], currentProfile: AppleProvisioningProfileFragment diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpProvisioningProfile-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpProvisioningProfile-test.ts index cb5d621ecf..189dfdf38d 100644 --- a/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpProvisioningProfile-test.ts +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpProvisioningProfile-test.ts @@ -19,8 +19,9 @@ import { ForbidCredentialModificationError, InsufficientAuthenticationNonInteractiveError, } from '../../../errors'; -import { AuthenticationMode } from '../../appstore/authenticateTypes'; +import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes'; import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile'; +import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils'; import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils'; import { SetUpProvisioningProfile } from '../SetUpProvisioningProfile'; @@ -30,11 +31,15 @@ jest.mock('../SetUpDistributionCertificate'); jest.mock('../ConfigureProvisioningProfile'); jest.mock('../CreateProvisioningProfile'); jest.mock('../../validators/validateProvisioningProfile'); +jest.mock('../AscApiKeyUtils'); jest.mock('../../../../graphql/queries/AppQuery'); describe('SetUpProvisioningProfile', () => { beforeEach(() => { + delete process.env.EXPO_APPLE_TEAM_TYPE; jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse); + jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockReset(); + jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(false); }); const testCases = ['NON_INTERACTIVE', 'INTERACTIVE']; @@ -262,4 +267,270 @@ describe('SetUpProvisioningProfile', () => { // expect provisioning profile not to be deleted on expo servers expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0); }); + + it('auto-authenticates before validation in nonInteractive mode when credentials are already valid', async () => { + let authAttemptedBeforeValidation = false; + jest + .mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync) + .mockImplementation(async authedCtx => { + authAttemptedBeforeValidation = true; + (authedCtx.appStore as any).authCtx = testAuthCtx; + return true; + }); + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => { + expect(authAttemptedBeforeValidation).toBe(true); + return true; + }); + const ctx = createCtxMock({ + nonInteractive: true, + appStore: { + ...getAppstoreMock(), + authCtx: undefined, + }, + ios: { + ...getNewIosApiMock(), + getIosAppCredentialsWithBuildCredentialsAsync: jest.fn( + () => testCommonIosAppCredentialsFragment + ), + }, + }); + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.AppStore + ); + await setupProvisioningProfileAction.runAsync(ctx); + + expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([ + [ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION], + ]); + expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe( + 0 + ); + expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0); + }); + + it('uses best-effort validation when Apple validation fails in nonInteractive mode', async () => { + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => { + throw new Error('Apple API request failed'); + }); + const ctx = createCtxMock({ + nonInteractive: true, + appStore: { + ...getAppstoreMock(), + authCtx: testAuthCtx, + }, + ios: { + ...getNewIosApiMock(), + getIosAppCredentialsWithBuildCredentialsAsync: jest.fn( + () => testCommonIosAppCredentialsFragment + ), + }, + }); + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.AppStore + ); + + await expect(setupProvisioningProfileAction.runAsync(ctx)).resolves.toEqual( + testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0] + ); + + expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(0); + expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe( + 0 + ); + expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0); + }); + + it('rethrows Apple validation failures in interactive mode', async () => { + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => { + throw new Error('Apple API request failed'); + }); + const ctx = createCtxMock({ + nonInteractive: false, + appStore: { + ...getAppstoreMock(), + authCtx: testAuthCtx, + }, + ios: { + ...getNewIosApiMock(), + getIosAppCredentialsWithBuildCredentialsAsync: jest.fn( + () => testCommonIosAppCredentialsFragment + ), + }, + }); + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.AppStore + ); + + await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrow( + 'Apple API request failed' + ); + expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe( + 0 + ); + expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0); + }); + + it('repairs the Provisioning Profile in nonInteractive mode after auto-authenticating with the EAS-stored ASC API key', async () => { + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false); + const appstoreMock = getAppstoreMock(); + const ctx = createCtxMock({ + nonInteractive: true, + appStore: { + ...appstoreMock, + defaultAuthenticationMode: AuthenticationMode.USER, + authCtx: undefined, + ensureAuthenticatedAsync: jest.fn(() => testAuthCtx), + listProvisioningProfilesAsync: jest.fn(() => [ + { + provisioningProfileId: nullthrows( + testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].provisioningProfile + ).developerPortalIdentifier, + }, + ]), + }, + ios: { + ...getNewIosApiMock(), + getIosAppCredentialsWithBuildCredentialsAsync: jest.fn( + () => testCommonIosAppCredentialsFragment + ), + createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment), + createOrUpdateIosAppBuildCredentialsAsync: jest.fn( + () => testIosAppBuildCredentialsFragment + ), + }, + }); + jest + .mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync) + .mockImplementation(async authedCtx => { + (authedCtx.appStore as any).authCtx = testAuthCtx; + return true; + }); + + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.AppStore + ); + await setupProvisioningProfileAction.runAsync(ctx); + + // expect we attempted to auto-authenticate with the EAS-stored ASC API key + expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([ + [ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION], + ]); + // expect build credentials to be created or updated on expo servers + expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe( + 1 + ); + // expect provisioning profile not to be deleted on expo servers (it still exists on Apple) + expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0); + }); + + it('passes IN_HOUSE team type for enterprise profiles when auto-authenticating in nonInteractive mode', async () => { + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false); + const appstoreMock = getAppstoreMock(); + const ctx = createCtxMock({ + nonInteractive: true, + appStore: { + ...appstoreMock, + defaultAuthenticationMode: AuthenticationMode.USER, + authCtx: undefined, + }, + }); + + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.Enterprise + ); + await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError( + InsufficientAuthenticationNonInteractiveError + ); + + expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([ + [ctx, appLookupParams, AppleTeamType.IN_HOUSE], + ]); + }); + + it('uses EXPO_APPLE_TEAM_TYPE over derived team type when auto-authenticating in nonInteractive mode', async () => { + process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.IN_HOUSE; + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false); + const appstoreMock = getAppstoreMock(); + const ctx = createCtxMock({ + nonInteractive: true, + appStore: { + ...appstoreMock, + defaultAuthenticationMode: AuthenticationMode.USER, + authCtx: undefined, + }, + }); + + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.AppStore + ); + await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError( + InsufficientAuthenticationNonInteractiveError + ); + + expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([ + [ctx, appLookupParams, AppleTeamType.IN_HOUSE], + ]); + }); + + it('errors in nonInteractive mode when the profile needs regeneration but no Apple authentication can be established', async () => { + jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false); + // tryAuthenticate fails to establish auth (default mock returns false and leaves authCtx unset). + const ctx = createCtxMock({ + nonInteractive: true, + }); + const appLookupParams = await getAppLookupParamsFromContextAsync( + ctx, + findApplicationTarget(testTargets) + ); + const setupProvisioningProfileAction = new SetUpProvisioningProfile( + appLookupParams, + testTarget, + IosDistributionType.AppStore + ); + await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError( + InsufficientAuthenticationNonInteractiveError + ); + + expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(1); + expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe( + 0 + ); + expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0); + }); }); diff --git a/packages/eas-cli/src/credentials/ios/appstore/__tests__/resolveCredentials-test.ts b/packages/eas-cli/src/credentials/ios/appstore/__tests__/resolveCredentials-test.ts index 42a2824403..981ba7191e 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/__tests__/resolveCredentials-test.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/__tests__/resolveCredentials-test.ts @@ -6,6 +6,7 @@ import { promptAsync } from '../../../../prompts'; import { AppleTeamType } from '../authenticateTypes'; import * as Keychain from '../keychain'; import { + resolveAppleTeamTypeFromEnvironment, resolveAppleTeamAsync, resolveAscApiKeyAsync, resolveUserCredentialsAsync, @@ -73,6 +74,17 @@ const testTeam = { teamName: 'test-name', teamType: AppleTeamType.IN_HOUSE, }; +describe(resolveAppleTeamTypeFromEnvironment, () => { + it('returns undefined when EXPO_APPLE_TEAM_TYPE is not set', () => { + expect(resolveAppleTeamTypeFromEnvironment()).toBeUndefined(); + }); + + it('returns parsed team type when EXPO_APPLE_TEAM_TYPE is set', () => { + process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.COMPANY_OR_ORGANIZATION; + expect(resolveAppleTeamTypeFromEnvironment()).toBe(AppleTeamType.COMPANY_OR_ORGANIZATION); + }); +}); + describe(resolveAppleTeamAsync, () => { it(`uses option overrides over environment variables`, async () => { process.env.EXPO_APPLE_TEAM_ID = 'not supposed to be here'; diff --git a/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts b/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts index a1fcf6246f..5d4b499480 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts @@ -118,7 +118,7 @@ function assertAppleTeamType(maybeTeamType: any): AppleTeamType { return maybeTeamType; } -function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined { +export function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined { if (!process.env.EXPO_APPLE_TEAM_TYPE) { return undefined; }