diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a0d0fef9..26757298f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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)) +- [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/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index a36e83df9d..e5fde0450e 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -6,7 +6,11 @@ import { UserRole } from '@expo/apple-utils'; import { formatAppleTeam } from './AppleTeamFormatting'; import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery'; import Log, { learnMore } from '../../../log'; +import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient'; +import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { confirmAsync, promptAsync, selectAsync } from '../../../prompts'; import { fromNow } from '../../../utils/date'; import { CredentialsContext } from '../../context'; @@ -277,3 +281,32 @@ 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; + } + + 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, + }; +} diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts index 9d760114bf..5a73b2f7ee 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import nullthrows from 'nullthrows'; import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils'; +import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils'; import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils'; import { chooseDevicesAsync, @@ -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'; @@ -407,6 +404,9 @@ export class SetUpAdhocProvisioningProfile { ctx: CredentialsContext, app: AppLookupParams ): Promise { + if (ctx.appStore.authCtx) { + return; + } if (hasAscEnvVars()) { await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY }); return; @@ -436,35 +436,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/SetUpDistributionCertificate.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts index 45dd6cd8bc..4f23927bfa 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils'; +import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils'; import { CreateDistributionCertificate } from './CreateDistributionCertificate'; import { formatDistributionCertificate } from './DistributionCertificateUtils'; import { @@ -16,6 +17,8 @@ import { MissingCredentialsNonInteractiveError } from '../../errors'; import { AppleDistributionCertificateMutationResult } from '../api/graphql/mutations/AppleDistributionCertificateMutation'; import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; import { getValidCertSerialNumbers } from '../appstore/CredentialsUtils'; +import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes'; +import { hasAscEnvVars } from '../appstore/resolveCredentials'; import { AppleTeamMissingError } from '../errors'; export class SetUpDistributionCertificate { @@ -50,13 +53,64 @@ export class SetUpDistributionCertificate { } } + private async ensureAppStoreAuthenticatedForDistCertRefreshAsync( + ctx: CredentialsContext + ): Promise { + if (ctx.appStore.authCtx) { + return; + } + if (hasAscEnvVars()) { + await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY }); + return; + } + + const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({ + graphqlClient: ctx.graphqlClient, + app: this.app, + }); + if (!resolvedKey) { + throw new Error( + 'No App Store Connect API Key found for distribution certificate 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, + teamType: AppleTeamType.COMPANY_OR_ORGANIZATION, + }); + } + private async runNonInteractiveAsync( - _ctx: CredentialsContext, + ctx: CredentialsContext, currentCertificate: AppleDistributionCertificateFragment | null ): Promise { - // TODO: implement validation + if (ctx.refreshDistributionCertificate) { + await this.ensureAppStoreAuthenticatedForDistCertRefreshAsync(ctx); + + if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) { + assert(currentCertificate, 'currentCertificate is defined here'); + Log.log('Using existing valid distribution certificate.'); + return currentCertificate; + } + + Log.warn('Current distribution certificate is invalid. Creating a new one...'); + 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; + } + return await this.createNewDistCertAsync(ctx); + } + Log.addNewLineIfNone(); - Log.warn('Distribution Certificate is not validated for non-interactive builds.'); + 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(); } 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..ade8066083 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/SetUpDistributionCertificate-test.ts @@ -0,0 +1,209 @@ +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 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 } = {}) { + const { authenticated = true } = options; + const validCert = createValidCert(); + const ctx = createCtxMock({ + nonInteractive: true, + refreshDistributionCertificate: true, + appStore: { + authCtx: authenticated ? testAuthCtx : undefined, + ensureAuthenticatedAsync: jest.fn().mockResolvedValue(testAuthCtx), + listDistributionCertificatesAsync: jest + .fn() + .mockResolvedValue(mockApplePortalCert(validCert.serialNumber)), + }, + }); + ctx.ios.getDistributionCertificateForAppAsync = jest.fn().mockResolvedValue(validCert); + ctx.ios.getDistributionCertificatesForAccountAsync = jest.fn().mockResolvedValue([validCert]); + return { ctx, validCert }; + } + + 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(); + }); + + describe('ensureAppStoreAuthenticatedForDistCertRefreshAsync', () => { + 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, + }); + 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('errors when no App Store Connect API key is available', async () => { + const { ctx } = setUpRefreshCtx({ authenticated: false }); + + await expect(setUpDistributionCertificate.runAsync(ctx)).rejects.toThrow( + 'No App Store Connect API Key found for distribution certificate refresh' + ); + }); + + it('skips authentication when already authenticated', async () => { + const { ctx } = setUpRefreshCtx(); + + await setUpDistributionCertificate.runAsync(ctx); + + expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled(); + }); + }); +});