diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a41b8e09a..58aa9a923d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp)) - [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)) +- [eas-cli] Add `--refresh-distribution-certificate` flag to validate and refresh the distribution certificate from App Store Connect before gathering build credentials in non-interactive mode. ([#3739](https://github.com/expo/eas-cli/pull/3739) by [@sswrk](https://github.com/sswrk)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/build/createContext.ts b/packages/eas-cli/src/build/createContext.ts index fb839faab2..cceed45ca0 100644 --- a/packages/eas-cli/src/build/createContext.ts +++ b/packages/eas-cli/src/build/createContext.ts @@ -43,6 +43,7 @@ export async function createBuildContextAsync({ buildLoggerLevel, freezeCredentials, refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag, + refreshDistributionCertificate: refreshDistributionCertificateFlag, isVerboseLoggingEnabled, whatToTest, env, @@ -67,6 +68,7 @@ export async function createBuildContextAsync({ buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; refreshAdHocProvisioningProfile?: boolean; + refreshDistributionCertificate?: boolean; isVerboseLoggingEnabled: boolean; whatToTest?: string; env: Record; @@ -93,6 +95,7 @@ export async function createBuildContextAsync({ const requiredPackageManager = resolvePackageManager(projectDir); const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false; + const refreshDistributionCertificate = refreshDistributionCertificateFlag ?? false; const credentialsCtx = new CredentialsContext({ projectInfo: { exp, projectId }, @@ -106,6 +109,7 @@ export async function createBuildContextAsync({ vcsClient, freezeCredentials, refreshAdHocProvisioningProfile, + refreshDistributionCertificate, }); 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 index 4cfb91d4a8..7c5c20b5db 100644 --- a/packages/eas-cli/src/build/ios/__tests__/credentials-test.ts +++ b/packages/eas-cli/src/build/ios/__tests__/credentials-test.ts @@ -37,4 +37,37 @@ describe(ensureIosCredentialsAsync, () => { await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined(); }); + + it('errors when distribution certificate refresh is enabled with local credentials source', async () => { + const buildCtx = { + buildProfile: { + credentialsSource: CredentialsSource.LOCAL, + simulator: false, + withoutCredentials: false, + }, + credentialsCtx: { + refreshDistributionCertificate: true, + }, + } as BuildContext; + + await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow( + '--refresh-distribution-certificate cannot be used with credentialsSource "local". Use remote credentials or omit the flag.' + ); + }); + + it('does not reject distribution certificate refresh when credentials source is remote', async () => { + const buildCtx = { + buildProfile: { + credentialsSource: CredentialsSource.REMOTE, + simulator: true, + withoutCredentials: false, + distribution: 'store', + }, + credentialsCtx: { + refreshDistributionCertificate: 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 d80310c570..abf83389ea 100644 --- a/packages/eas-cli/src/build/ios/credentials.ts +++ b/packages/eas-cli/src/build/ios/credentials.ts @@ -26,6 +26,11 @@ export async function ensureIosCredentialsAsync( '--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.' ); } + if (buildCtx.credentialsCtx.refreshDistributionCertificate && credentialsSource === 'local') { + throw new Error( + '--refresh-distribution-certificate cannot be used with credentialsSource "local". Use remote credentials or omit the flag.' + ); + } const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, { app: await getAppFromContextAsync(buildCtx.credentialsCtx), diff --git a/packages/eas-cli/src/build/runBuildAndSubmit.ts b/packages/eas-cli/src/build/runBuildAndSubmit.ts index 28ab4ceea3..91123c9e94 100644 --- a/packages/eas-cli/src/build/runBuildAndSubmit.ts +++ b/packages/eas-cli/src/build/runBuildAndSubmit.ts @@ -104,6 +104,7 @@ export interface BuildFlags { buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; refreshAdHocProvisioningProfile?: boolean; + refreshDistributionCertificate?: boolean; isVerboseLoggingEnabled?: boolean; whatToTest?: string; simulator?: SimulatorRunTarget; @@ -415,6 +416,7 @@ async function prepareAndStartBuildAsync({ buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined), freezeCredentials: flags.freezeCredentials, refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile, + refreshDistributionCertificate: flags.refreshDistributionCertificate, 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 65c00cbcc7..698a0c6ad2 100644 --- a/packages/eas-cli/src/build/types.ts +++ b/packages/eas-cli/src/build/types.ts @@ -36,4 +36,5 @@ export interface BuildFlags { buildLoggerLevel?: LoggerLevel; freezeCredentials: boolean; refreshAdHocProvisioningProfile?: boolean; + refreshDistributionCertificate?: boolean; } diff --git a/packages/eas-cli/src/commands/build/index.ts b/packages/eas-cli/src/commands/build/index.ts index 77460b0d0a..f840cdefd9 100644 --- a/packages/eas-cli/src/commands/build/index.ts +++ b/packages/eas-cli/src/commands/build/index.ts @@ -41,6 +41,7 @@ interface RawBuildFlags { 'build-logger-level'?: LoggerLevel; 'freeze-credentials': boolean; 'refresh-ad-hoc-provisioning-profile': boolean; + 'refresh-distribution-certificate': boolean; 'verbose-logs'?: boolean; 'what-to-test'?: string; } @@ -127,6 +128,11 @@ export default class Build extends EasCommand { description: 'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials', }), + 'refresh-distribution-certificate': Flags.boolean({ + default: false, + description: + 'Validate and refresh the distribution certificate from App Store Connect before gathering build credentials', + }), 'verbose-logs': Flags.boolean({ default: false, description: 'Use verbose logs for the build process', @@ -208,6 +214,20 @@ export default class Build extends EasCommand { ); } } + if (flags['refresh-distribution-certificate']) { + if (!nonInteractive) { + Errors.error( + '--refresh-distribution-certificate can only be used in non-interactive mode.', + { exit: 1 } + ); + } + if (flags['freeze-credentials']) { + Errors.error( + 'Cannot use --refresh-distribution-certificate with --freeze-credentials.', + { exit: 1 } + ); + } + } if (!flags.local && flags.output) { Errors.error('--output is allowed only for local builds', { exit: 1 }); } @@ -270,6 +290,7 @@ export default class Build extends EasCommand { buildLoggerLevel: flags['build-logger-level'], freezeCredentials: flags['freeze-credentials'], refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'], + refreshDistributionCertificate: flags['refresh-distribution-certificate'], 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 8762521e7a..7119757793 100644 --- a/packages/eas-cli/src/credentials/context.ts +++ b/packages/eas-cli/src/credentials/context.ts @@ -28,6 +28,7 @@ export class CredentialsContext { public readonly autoAcceptCredentialReuse: boolean; public readonly freezeCredentials: boolean = false; public readonly refreshAdHocProvisioningProfile: boolean = false; + public readonly refreshDistributionCertificate: boolean = false; public readonly projectDir: string; public readonly user: Actor; public readonly graphqlClient: ExpoGraphqlClient; @@ -54,6 +55,7 @@ export class CredentialsContext { freezeCredentials?: boolean; autoAcceptCredentialReuse?: boolean; refreshAdHocProvisioningProfile?: boolean; + refreshDistributionCertificate?: boolean; env?: Env; } ) { @@ -68,6 +70,7 @@ export class CredentialsContext { this.projectInfo = options.projectInfo; this.freezeCredentials = options.freezeCredentials ?? false; this.refreshAdHocProvisioningProfile = options.refreshAdHocProvisioningProfile ?? false; + this.refreshDistributionCertificate = options.refreshDistributionCertificate ?? false; this.usesBroadcastPushNotifications = options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false; } diff --git a/packages/eas-cli/src/credentials/ios/__tests__/IosCredentialsProvider-test.ts b/packages/eas-cli/src/credentials/ios/__tests__/IosCredentialsProvider-test.ts index a16eed27ab..49b7b05a9a 100644 --- a/packages/eas-cli/src/credentials/ios/__tests__/IosCredentialsProvider-test.ts +++ b/packages/eas-cli/src/credentials/ios/__tests__/IosCredentialsProvider-test.ts @@ -86,11 +86,17 @@ describe(IosCredentialsProvider, () => { getIosAppCredentialsWithBuildCredentialsAsync: jest.fn( () => testCommonIosAppCredentialsFragment ), - getDistributionCertificateForAppAsync: jest.fn( - () => + getDistributionCertificateForAppAsync: jest.fn(() => { + const distributionCertificate = testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0] - .distributionCertificate - ), + .distributionCertificate; + const now = Date.now(); + return { + ...distributionCertificate, + validityNotBefore: new Date(now - 86_400_000), + validityNotAfter: new Date(now + 86_400_000 * 365), + }; + }), }, }); const appLookupParams = await getAppLookupParamsFromContextAsync( diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts index 20c5ae4a42..f3eeb3b0d4 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts @@ -11,7 +11,7 @@ import { filterDevicesForApplePlatform, formatDeviceLabel, } from './DeviceUtils'; -import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils'; +import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils'; import { SetUpDistributionCertificate } from './SetUpDistributionCertificate'; import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action'; import { @@ -33,12 +33,14 @@ import { } from '../../../prompts'; import differenceBy from '../../../utils/expodash/differenceBy'; import { CredentialsContext } from '../../context'; -import { MissingCredentialsNonInteractiveError } from '../../errors'; +import { + InsufficientAuthenticationNonInteractiveError, + MissingCredentialsNonInteractiveError, +} from '../../errors'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; -import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes'; +import { AppleTeamType } from '../appstore/authenticateTypes'; import { ProvisioningProfile } from '../appstore/Credentials.types'; import { ApplePlatform } from '../appstore/constants'; -import { hasAscEnvVars } from '../appstore/resolveCredentials'; import { Target } from '../types'; import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile'; @@ -71,7 +73,18 @@ export class SetUpAdhocProvisioningProfile { ).runAsync(ctx); if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) { - await this.ensureAppStoreAuthenticatedForAdhocRefreshAsync(ctx, app); + const authenticated = await tryAuthenticateAppStoreWithEasAscApiKeyAsync( + ctx, + app, + AppleTeamType.COMPANY_OR_ORGANIZATION + ); + if (!authenticated) { + throw new InsufficientAuthenticationNonInteractiveError( + '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' + ); + } return await this.runWithDistributionCertificateAsync(ctx, distCert); } @@ -399,39 +412,6 @@ 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' - ); - } - - 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, - // 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, - }); - } } export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean { diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts index 45dd6cd8bc..e5a4f87e67 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts @@ -1,6 +1,5 @@ -import assert from 'assert'; - import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils'; +import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils'; import { CreateDistributionCertificate } from './CreateDistributionCertificate'; import { formatDistributionCertificate } from './DistributionCertificateUtils'; import { @@ -12,10 +11,15 @@ import Log from '../../../log'; import { confirmAsync, promptAsync } from '../../../prompts'; import sortBy from '../../../utils/expodash/sortBy'; import { CredentialsContext } from '../../context'; -import { MissingCredentialsNonInteractiveError } from '../../errors'; +import { + ForbidCredentialModificationError, + InsufficientAuthenticationNonInteractiveError, + MissingCredentialsNonInteractiveError, +} from '../../errors'; import { AppleDistributionCertificateMutationResult } from '../api/graphql/mutations/AppleDistributionCertificateMutation'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { getValidCertSerialNumbers } from '../appstore/CredentialsUtils'; +import { AppleTeamType } from '../appstore/authenticateTypes'; import { AppleTeamMissingError } from '../errors'; export class SetUpDistributionCertificate { @@ -51,24 +55,70 @@ export class SetUpDistributionCertificate { } private async runNonInteractiveAsync( - _ctx: CredentialsContext, + ctx: CredentialsContext, currentCertificate: AppleDistributionCertificateFragment | null ): Promise { - // TODO: implement validation - Log.addNewLineIfNone(); - Log.warn('Distribution Certificate is not validated for non-interactive builds.'); + if (!ctx.refreshDistributionCertificate) { + Log.addNewLineIfNone(); + Log.warn( + 'Using the existing distribution certificate without validating it against Apple servers. Use --refresh-distribution-certificate to validate and refresh if needed.' + ); + if (!currentCertificate) { + throw new MissingCredentialsNonInteractiveError(); + } + return currentCertificate; + } + if (!currentCertificate) { throw new MissingCredentialsNonInteractiveError(); } - return currentCertificate; + + await tryAuthenticateAppStoreWithEasAscApiKeyAsync( + ctx, + this.app, + AppleTeamType.COMPANY_OR_ORGANIZATION + ); + + if ( + currentCertificate && + (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) + ) { + Log.log('Using existing valid distribution certificate.'); + return currentCertificate; + } + + if (ctx.freezeCredentials) { + throw new ForbidCredentialModificationError( + 'Distribution certificate is not configured correctly. Remove the --freeze-credentials flag to configure it.' + ); + } + + if (!ctx.appStore.authCtx) { + throw new InsufficientAuthenticationNonInteractiveError( + 'Authentication with an ASC API key is required to validate and refresh a distribution certificate 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' + ); + } + + const validDistCerts = await this.getValidDistCertsAsync(ctx); + if (validDistCerts.length > 0) { + const cert = validDistCerts[0]; + Log.log(`Reusing distribution certificate with serial number ${cert.serialNumber}`); + return cert; + } + Log.warn('Current distribution certificate is invalid. Creating a new one...'); + return await this.createNewDistCertAsync(ctx); } private async runInteractiveAsync( ctx: CredentialsContext, currentCertificate: AppleDistributionCertificateFragment | null ): Promise { - if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) { - assert(currentCertificate, 'currentCertificate is defined here'); + if ( + currentCertificate && + (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) + ) { return currentCertificate; } 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 0352ddbbe1..80c2ae441c 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,13 +23,17 @@ import { ApplePlatform } from '../../appstore/constants'; import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils'; import { chooseDevicesAsync } from '../DeviceUtils'; import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile'; +import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils'; import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient'; -import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes'; +import { AppleTeamType } from '../../appstore/authenticateTypes'; import { hasAscEnvVars } from '../../appstore/resolveCredentials'; -import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery'; jest.mock('../BuildCredentialsUtils'); jest.mock('../../../context'); +jest.mock('../AscApiKeyUtils', () => ({ + ...jest.requireActual('../AscApiKeyUtils'), + tryAuthenticateAppStoreWithEasAscApiKeyAsync: jest.fn(), +})); jest.mock('../../../ios/api/GraphqlClient', () => ({ ...jest.requireActual('../../../ios/api/GraphqlClient'), getAscApiKeyForAppSubmissionsAsync: jest.fn(), @@ -37,11 +41,6 @@ jest.mock('../../../ios/api/GraphqlClient', () => ({ jest.mock('../../appstore/resolveCredentials', () => ({ hasAscEnvVars: jest.fn(), })); -jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({ - AppStoreConnectApiKeyQuery: { - getByIdAsync: jest.fn(), - }, -})); jest.mock('../DeviceUtils', () => { return { __esModule: true, @@ -178,6 +177,7 @@ describe('runAsync', () => { runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment), }) as any ); + jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(true); }); const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({ @@ -192,12 +192,9 @@ describe('runAsync', () => { const areBuildCredentialsSetupAsyncSpy = jest .spyOn(SetUpAdhocProvisioningProfile.prototype as any, 'areBuildCredentialsSetupAsync') .mockResolvedValue(true); - const ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy = jest - .spyOn( - SetUpAdhocProvisioningProfile.prototype as any, - 'ensureAppStoreAuthenticatedForAdhocRefreshAsync' - ) - .mockResolvedValue(undefined); + const tryAuthenticateAppStoreWithEasAscApiKeyAsyncSpy = jest + .mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync) + .mockResolvedValue(true); jest .spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync') .mockResolvedValue({} as IosAppBuildCredentialsFragment); @@ -205,11 +202,15 @@ describe('runAsync', () => { await setUpAdhocProvisioningProfile.runAsync(ctx); expect(areBuildCredentialsSetupAsyncSpy).not.toHaveBeenCalled(); - expect(ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy).toHaveBeenCalledWith(ctx, { - account: {} as Account, - projectName: 'projName', - bundleIdentifier: 'bundleId', - }); + expect(tryAuthenticateAppStoreWithEasAscApiKeyAsyncSpy).toHaveBeenCalledWith( + ctx, + { + account: {} as Account, + projectName: 'projName', + bundleIdentifier: 'bundleId', + }, + AppleTeamType.COMPANY_OR_ORGANIZATION + ); expect( SetUpAdhocProvisioningProfile.prototype.runWithDistributionCertificateAsync ).toHaveBeenCalled(); @@ -320,54 +321,67 @@ describe('refresh ad-hoc provisioning profile', () => { ); }); - describe('ensureAppStoreAuthenticatedForAdhocRefreshAsync', () => { + describe('tryAuthenticateAppStoreWithEasAscApiKeyAsync', () => { + beforeEach(() => { + jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockReset(); + }); + it('authenticates with ASC environment variables when present', async () => { const { ctx } = setUpRefreshTest(); jest.mocked(hasAscEnvVars).mockReturnValue(true); + jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockImplementation(async ctx => { + ctx.appStore.authCtx = {} as any; + return 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(); + expect(tryAuthenticateAppStoreWithEasAscApiKeyAsync).toHaveBeenCalledWith( + ctx, + { + account: {} as Account, + projectName: 'projName', + bundleIdentifier: 'bundleId', + }, + AppleTeamType.COMPANY_OR_ORGANIZATION + ); }); 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.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(true); 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', + expect(tryAuthenticateAppStoreWithEasAscApiKeyAsync).toHaveBeenCalledWith( + ctx, + { + account: {} as Account, + projectName: 'projName', + bundleIdentifier: 'bundleId', }, - teamId: 'TEAM123', - teamName: 'Team Name', - teamType: AppleTeamType.COMPANY_OR_ORGANIZATION, - }); + AppleTeamType.COMPANY_OR_ORGANIZATION + ); + }); + + it('skips re-authentication when already authenticated', async () => { + const { ctx } = setUpRefreshTest(); + ctx.appStore.authCtx = {} as any; + jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(true); + jest + .spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync') + .mockResolvedValue({} as IosAppBuildCredentialsFragment); + + await setUpAdhocProvisioningProfile.runAsync(ctx); + + expect(tryAuthenticateAppStoreWithEasAscApiKeyAsync).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpDistributionCertificate-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpDistributionCertificate-test.ts new file mode 100644 index 0000000000..d3baa2af9a --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpDistributionCertificate-test.ts @@ -0,0 +1,305 @@ +import { + AppleDistributionCertificateFragment, + IosDistributionType, +} from '../../../../graphql/generated'; +import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery'; +import { createCtxMock } from '../../../__tests__/fixtures-context'; +import { testAuthCtx } from '../../../__tests__/fixtures-appstore'; +import { + testAppFragment, + testAppleTeamFragment, + testDistCertFragmentNoDependencies, +} from '../../../__tests__/fixtures-ios'; +import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient'; +import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes'; +import { hasAscEnvVars } from '../../appstore/resolveCredentials'; +import { resolveAppleTeamIfAuthenticatedAsync } from '../AppleTeamUtils'; +import { CreateDistributionCertificate } from '../CreateDistributionCertificate'; +import { SetUpDistributionCertificate } from '../SetUpDistributionCertificate'; + +jest.mock('../AppleTeamUtils'); +jest.mock('../CreateDistributionCertificate'); +jest.mock('../../appstore/resolveCredentials', () => ({ + hasAscEnvVars: jest.fn(), +})); +jest.mock('../../api/GraphqlClient', () => ({ + ...jest.requireActual('../../api/GraphqlClient'), + getAscApiKeyForAppSubmissionsAsync: jest.fn(), +})); +jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({ + AppStoreConnectApiKeyQuery: { + getByIdAsync: jest.fn(), + }, +})); + +const app = { + account: testAppFragment.ownerAccount, + projectName: 'testproject', + bundleIdentifier: 'foo.bar.com', +}; + +function createValidCert( + overrides: Partial = {} +): AppleDistributionCertificateFragment { + const now = Date.now(); + return { + ...testDistCertFragmentNoDependencies, + serialNumber: 'valid-serial', + validityNotBefore: new Date(now - 86_400_000), + validityNotAfter: new Date(now + 86_400_000 * 365), + appleTeam: testAppleTeamFragment, + ...overrides, + }; +} + +function createExpiredCert( + overrides: Partial = {} +): AppleDistributionCertificateFragment { + const now = Date.now(); + return createValidCert({ + validityNotBefore: new Date(now - 86_400_000 * 365), + validityNotAfter: new Date(now - 86_400_000), + ...overrides, + }); +} + +function mockApplePortalCert(serialNumber: string) { + return [ + { + id: 'cert-id', + name: 'cert', + status: 'valid', + created: 0, + expires: Math.floor(Date.now() / 1000) + 86_400 * 365, + ownerName: 'owner', + serialNumber, + }, + ]; +} + +describe('SetUpDistributionCertificate refresh distribution certificate', () => { + let setUpDistributionCertificate: SetUpDistributionCertificate; + + beforeEach(() => { + jest.clearAllMocks(); + setUpDistributionCertificate = new SetUpDistributionCertificate( + app, + IosDistributionType.AppStore + ); + jest.mocked(resolveAppleTeamIfAuthenticatedAsync).mockResolvedValue(null); + jest.mocked(hasAscEnvVars).mockReturnValue(false); + jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue(null); + }); + + function setUpRefreshCtx(options: { authenticated?: boolean; freezeCredentials?: boolean } = {}) { + const { authenticated = true } = options; + const validCert = createValidCert(); + const ctx = createCtxMock({ + nonInteractive: true, + refreshDistributionCertificate: true, + freezeCredentials: options.freezeCredentials, + appStore: { + authCtx: authenticated ? testAuthCtx : undefined, + listDistributionCertificatesAsync: jest + .fn() + .mockResolvedValue(mockApplePortalCert(validCert.serialNumber)), + }, + }); + ctx.appStore.ensureAuthenticatedAsync = jest.fn(async () => { + ctx.appStore.authCtx = testAuthCtx; + return testAuthCtx; + }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(validCert); + ctx.ios.getDistributionCertificatesForAccountAsync = jest.fn().mockResolvedValue([validCert]); + return { ctx, validCert }; + } + + describe('default non-interactive path without --refresh-distribution-certificate', () => { + it('returns the stored distribution certificate without authenticating or validating', async () => { + const validCert = createValidCert(); + const ctx = createCtxMock({ + nonInteractive: true, + appStore: { + authCtx: undefined, + }, + }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(validCert); + + const result = await setUpDistributionCertificate.runAsync(ctx); + + expect(result).toBe(validCert); + expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled(); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('errors when no distribution certificate is configured', async () => { + const ctx = createCtxMock({ + nonInteractive: true, + }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(null); + + await expect(setUpDistributionCertificate.runAsync(ctx)).rejects.toThrow( + 'Credentials are not set up' + ); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + }); + + it('errors when no distribution certificate is configured in refresh mode', async () => { + const { ctx } = setUpRefreshCtx({ authenticated: false }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(null); + + await expect(setUpDistributionCertificate.runAsync(ctx)).rejects.toThrow( + 'Credentials are not set up' + ); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('uses existing valid distribution certificate in refresh mode', async () => { + const { ctx, validCert } = setUpRefreshCtx(); + + const result = await setUpDistributionCertificate.runAsync(ctx); + + expect(result).toBe(validCert); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('reuses a valid distribution certificate from Apple when the current one is invalid', async () => { + const { ctx } = setUpRefreshCtx(); + const invalidCurrentCert = createValidCert({ serialNumber: 'invalid-serial' }); + const reusableCert = createValidCert({ serialNumber: 'reusable-serial', id: 'reusable-id' }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(invalidCurrentCert); + ctx.appStore.listDistributionCertificatesAsync = jest + .fn() + .mockResolvedValue(mockApplePortalCert('reusable-serial')); + ctx.ios.getDistributionCertificatesForAccountAsync = jest + .fn() + .mockResolvedValue([invalidCurrentCert, reusableCert]); + + const result = await setUpDistributionCertificate.runAsync(ctx); + + expect(result).toBe(reusableCert); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('creates a new distribution certificate when no valid certificates are available', async () => { + const { ctx } = setUpRefreshCtx(); + const invalidCurrentCert = createValidCert({ serialNumber: 'invalid-serial' }); + const newCert = createValidCert({ serialNumber: 'new-serial', id: 'new-cert-id' }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(invalidCurrentCert); + ctx.appStore.listDistributionCertificatesAsync = jest.fn().mockResolvedValue([]); + ctx.ios.getDistributionCertificatesForAccountAsync = jest.fn().mockResolvedValue([]); + jest.mocked(CreateDistributionCertificate).mockImplementation( + () => + ({ + runAsync: jest.fn().mockResolvedValue(newCert), + }) as any + ); + + const result = await setUpDistributionCertificate.runAsync(ctx); + + expect(result).toBe(newCert); + expect(CreateDistributionCertificate).toHaveBeenCalled(); + }); + + it('continues with a locally valid certificate when no App Store Connect API key is available', async () => { + const { ctx, validCert } = setUpRefreshCtx({ authenticated: false }); + + const result = await setUpDistributionCertificate.runAsync(ctx); + + expect(result).toBe(validCert); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('propagates Apple validation errors', async () => { + const { ctx } = setUpRefreshCtx(); + ctx.appStore.listDistributionCertificatesAsync = jest + .fn() + .mockRejectedValue(new Error('Apple API unavailable')); + + await expect(setUpDistributionCertificate.runAsync(ctx)).rejects.toThrow( + 'Apple API unavailable' + ); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('blocks repair when credentials are frozen', async () => { + const { ctx } = setUpRefreshCtx({ freezeCredentials: true }); + const invalidCurrentCert = createValidCert({ serialNumber: 'invalid-serial' }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(invalidCurrentCert); + ctx.appStore.listDistributionCertificatesAsync = jest + .fn() + .mockResolvedValue(mockApplePortalCert('valid-serial')); + + await expect(setUpDistributionCertificate.runAsync(ctx)).rejects.toThrow( + 'Distribution certificate is not configured correctly. Remove the --freeze-credentials flag to configure it.' + ); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + it('errors when repair is required but App Store Connect authentication is unavailable', async () => { + const { ctx } = setUpRefreshCtx({ authenticated: false }); + ctx.ios.getDistributionCertificateForAppAsync = jest + .fn() + .mockResolvedValue(createExpiredCert()); + + await expect(setUpDistributionCertificate.runAsync(ctx)).rejects.toThrow( + 'Authentication with an ASC API key is required to validate and refresh a distribution certificate in non-interactive mode.' + ); + expect(CreateDistributionCertificate).not.toHaveBeenCalled(); + }); + + describe('tryAuthenticateAppStoreWithEasAscApiKeyAsync', () => { + it('authenticates with ASC environment variables when present', async () => { + const { ctx } = setUpRefreshCtx({ authenticated: false }); + jest.mocked(hasAscEnvVars).mockReturnValue(true); + + await setUpDistributionCertificate.runAsync(ctx); + + expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({ + mode: AuthenticationMode.API_KEY, + teamType: AppleTeamType.COMPANY_OR_ORGANIZATION, + }); + expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled(); + }); + + it('authenticates with the stored submissions ASC API key when env vars are absent', async () => { + const { ctx } = setUpRefreshCtx({ authenticated: false }); + 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); + + await setUpDistributionCertificate.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, + }); + }); + + it('skips authentication when already authenticated', async () => { + const { ctx } = setUpRefreshCtx(); + + await setUpDistributionCertificate.runAsync(ctx); + + expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled(); + }); + }); +});