From 673d9979e766591d99c010e9ec6cac1f1b5ce661 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Fri, 21 Mar 2025 15:44:03 -0500 Subject: [PATCH] Save a provisioning profile to flutter config for manual code-signing (#164984) This adds the ability to save a provisioning profile to the flutter config. It introduces a flag `flutter config --select-ios-signing-settings` which prompts the user to select either Automatic or Manual code-signing and then select a code-signing identity/cert for Automatic or a provisioning profile for Manual. Fixes https://github.com/flutter/flutter/issues/164983. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../lib/src/commands/config.dart | 31 +- .../lib/src/commands/create.dart | 3 + .../lib/src/ios/code_signing.dart | 983 +++++-- packages/flutter_tools/lib/src/ios/mac.dart | 3 + .../flutter_tools/lib/src/xcode_project.dart | 3 + .../general.shard/ios/code_signing_test.dart | 2529 +++++++++++++---- 6 files changed, 2819 insertions(+), 733 deletions(-) diff --git a/packages/flutter_tools/lib/src/commands/config.dart b/packages/flutter_tools/lib/src/commands/config.dart index 6270b5b356..dac09a2262 100644 --- a/packages/flutter_tools/lib/src/commands/config.dart +++ b/packages/flutter_tools/lib/src/commands/config.dart @@ -9,6 +9,7 @@ import '../base/common.dart'; import '../convert.dart'; import '../features.dart'; import '../globals.dart' as globals; +import '../ios/code_signing.dart'; import '../runner/flutter_command.dart'; import '../runner/flutter_command_runner.dart'; @@ -28,10 +29,17 @@ class ConfigCommand extends FlutterCommand { 'and "--${FlutterGlobalOptions.kDisableAnalyticsFlag}" top level flags.)', ); argParser.addFlag( - 'clear-ios-signing-cert', + 'clear-ios-signing-settings', + negatable: false, + aliases: ['clear-ios-signing-cert'], + help: + 'Clear the saved development certificate or provisioning profile choice used to sign apps for iOS device deployment.', + ); + argParser.addFlag( + 'select-ios-signing-settings', negatable: false, help: - 'Clear the saved development certificate choice used to sign apps for iOS device deployment.', + 'Complete prompt to select and save code signing settings used to sign apps for iOS device deployment.', ); argParser.addOption('android-sdk', help: 'The Android SDK directory.'); argParser.addOption( @@ -153,8 +161,23 @@ class ConfigCommand extends FlutterCommand { _updateConfig('jdk-dir', stringArg('jdk-dir')!); } - if (argResults!.wasParsed('clear-ios-signing-cert')) { - _updateConfig('ios-signing-cert', ''); + if (argResults!.wasParsed('clear-ios-signing-settings')) { + XcodeCodeSigningSettings.resetSettings(globals.config, globals.logger); + } + + if (argResults!.wasParsed('select-ios-signing-settings')) { + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: globals.config, + logger: globals.logger, + platform: globals.platform, + processUtils: globals.processUtils, + fileSystem: globals.fs, + fileSystemUtils: globals.fsUtils, + terminal: globals.terminal, + plistParser: globals.plistParser, + ); + + await settings.selectSettings(); } if (argResults!.wasParsed('build-dir')) { diff --git a/packages/flutter_tools/lib/src/commands/create.dart b/packages/flutter_tools/lib/src/commands/create.dart index a200fb7722..fe90a365d5 100644 --- a/packages/flutter_tools/lib/src/commands/create.dart +++ b/packages/flutter_tools/lib/src/commands/create.dart @@ -400,6 +400,9 @@ class CreateCommand extends FlutterCommand with CreateBase { logger: globals.logger, config: globals.config, terminal: globals.terminal, + fileSystem: globals.fs, + fileSystemUtils: globals.fsUtils, + plistParser: globals.plistParser, ); } diff --git a/packages/flutter_tools/lib/src/ios/code_signing.dart b/packages/flutter_tools/lib/src/ios/code_signing.dart index a3e5a1cd62..59ffc6d3cc 100644 --- a/packages/flutter_tools/lib/src/ios/code_signing.dart +++ b/packages/flutter_tools/lib/src/ios/code_signing.dart @@ -8,14 +8,22 @@ import 'package:process/process.dart'; import '../base/common.dart'; import '../base/config.dart'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; import '../convert.dart' show utf8; +import 'plist_parser.dart'; const String _developmentTeamBuildSettingName = 'DEVELOPMENT_TEAM'; +const String _codeSignStyleBuildSettingName = 'CODE_SIGN_STYLE'; +const String _provisioningProfileSpecifierBuildSettingName = 'PROVISIONING_PROFILE_SPECIFIER'; +const String _provisioningProfileBuildSettingName = 'PROVISIONING_PROFILE'; + +const String _codeSignSelectionCanceled = + 'Code-signing setup canceled. Your changes have not been saved.'; /// User message when no development certificates are found in the keychain. /// @@ -83,21 +91,53 @@ const String fixWithDevelopmentTeamInstruction = ''' - Let Xcode automatically provision a profile for your app 4- Build or run your project again'''; +/// Pattern to extract identity from list of identities. +/// +/// Example: +/// +/// ` 1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)"` +/// extracts `iPhone Developer: Profile 1 (1111AAAA11)` final RegExp _securityFindIdentityDeveloperIdentityExtractionPattern = RegExp( r'^\s*\d+\).+"(.+Develop(ment|er).+)"$', ); + +/// Pattern to extract unique identifier from certificate Common Name. +/// +/// Example: +/// +/// `iPhone Developer: Profile 1 (1111AAAA11)` +/// extracts `1111AAAA11` final RegExp _securityFindIdentityCertificateCnExtractionPattern = RegExp(r'.*\(([a-zA-Z0-9]+)\)'); + +/// Pattern to extract OU (Organizational Unit) from certificate subject. +/// +/// Example: +/// +/// `subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O/O=Company LLC/C=US` +/// extracts `ABCDE1F2DH` final RegExp _certificateOrganizationalUnitExtractionPattern = RegExp(r'OU=([a-zA-Z0-9]+)'); -/// Given a [BuildableIOSApp], this will try to find valid development code -/// signing identities in the user's keychain prompting a choice if multiple +/// Pattern to extract CN (Common Name) from certificate subject. +/// +/// Example: +/// +/// `subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O/O=Company LLC/C=US` +/// extracts `Apple Development: Company Development (12ABCD234E)` +final RegExp _certificateCommonNameExtractionPattern = RegExp(r'CN=([a-zA-Z0-9\s:\(\)]+)'); + +/// Given a [BuildableIOSApp], find build settings for either automatic (identity) +/// or manual (provisioning profile) code-signing. +/// +/// If a valid provisioning profile or code-signing identity is saved in the +/// config, it will use that. Otherwise, it will try to find valid development +/// code-signing identities in the user's keychain, prompting a choice if multiple /// are found. /// -/// Returns a set of build configuration settings that uses the selected -/// signing identities. +/// Throws an error if the user cancels identity selection or if no identities +/// are found. /// -/// Will return null if none are found, if the user cancels or if the Xcode -/// project has a development team set in the project's build settings. +/// Will return null if the `DEVELOPMENT_TEAM` or `PROVISIONING_PROFILE` are +/// already set in the Xcode project's build settings. Future?> getCodeSigningIdentityDevelopmentTeamBuildSetting({ required Map buildSettings, required ProcessManager processManager, @@ -105,6 +145,9 @@ Future?> getCodeSigningIdentityDevelopmentTeamBuildSetting({ required Logger logger, required Config config, required Terminal terminal, + required FileSystem fileSystem, + required FileSystemUtils fileSystemUtils, + required PlistParser plistParser, }) async { // If the user already has it set in the project build settings itself, // continue with that. @@ -116,229 +159,791 @@ Future?> getCodeSigningIdentityDevelopmentTeamBuildSetting({ return null; } - if (_isNotEmpty(buildSettings['PROVISIONING_PROFILE'])) { + if (_isNotEmpty(buildSettings[_provisioningProfileBuildSettingName])) { return null; } - final String? developmentTeam = await _getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: platform, - logger: logger, + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( config: config, + logger: logger, + platform: platform, + processUtils: ProcessUtils(processManager: processManager, logger: logger), + fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, terminal: terminal, - shouldExitOnNoCerts: true, + plistParser: plistParser, ); - if (developmentTeam == null) { - return null; - } - - return {_developmentTeamBuildSettingName: developmentTeam}; + return settings._getCodeSigningBuildSettings(); } +/// Returns the `DEVELOPMENT_TEAM` for automatic code-signing. +/// This function should not be used for manual code-signing. +/// +/// This finds the `DEVELOPMENT_TEAM` from the saved `ios-signing-cert` or prompt the +/// user to select a code-signing identity for automatic code-signing if +/// `ios-signing-cert` is not saved or invalid. +/// +/// If `ios-signing-profile` (manual code-signing with a provisioning profile) +/// is saved instead, returns null. Future getCodeSigningIdentityDevelopmentTeam({ required ProcessManager processManager, required Platform platform, required Logger logger, required Config config, required Terminal terminal, -}) async => _getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: platform, - logger: logger, - config: config, - terminal: terminal, -); - -/// Set [shouldExitOnNoCerts] to show instructions for how to add a cert when none are found, then [toolExit]. -Future _getCodeSigningIdentityDevelopmentTeam({ - required ProcessManager processManager, - required Platform platform, - required Logger logger, - required Config config, - required Terminal terminal, - bool shouldExitOnNoCerts = false, + required FileSystem fileSystem, + required FileSystemUtils fileSystemUtils, + required PlistParser plistParser, }) async { - if (!platform.isMacOS) { - return null; - } - - // If the user's environment is missing the tools needed to find and read - // certificates, abandon. Tools should be pre-equipped on macOS. - final ProcessUtils processUtils = ProcessUtils(processManager: processManager, logger: logger); - if (!await processUtils.exitsHappy(const ['which', 'security']) || - !await processUtils.exitsHappy(const ['which', 'openssl'])) { - return null; - } - - const List findIdentityCommand = [ - 'security', - 'find-identity', - '-p', - 'codesigning', - '-v', - ]; - - String findIdentityStdout; - try { - findIdentityStdout = - (await processUtils.run(findIdentityCommand, throwOnError: true)).stdout.trim(); - } on ProcessException catch (error) { - logger.printTrace('Unexpected failure from find-identity: $error.'); - return null; - } - - final List validCodeSigningIdentities = - findIdentityStdout - .split('\n') - .map((String outputLine) { - return _securityFindIdentityDeveloperIdentityExtractionPattern - .firstMatch(outputLine) - ?.group(1); - }) - .where(_isNotEmpty) - .whereType() - .toSet() // Unique. - .toList(); - - final String? signingIdentity = await _chooseSigningIdentity( - validCodeSigningIdentities, - logger, - config, - terminal, - shouldExitOnNoCerts, + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: platform, + processUtils: ProcessUtils(processManager: processManager, logger: logger), + fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, + terminal: terminal, + plistParser: plistParser, ); - // If none are chosen, return null. - if (signingIdentity == null) { - return null; - } - - logger.printStatus('Developer identity "$signingIdentity" selected for iOS code signing'); - - final String? signingCertificateId = _securityFindIdentityCertificateCnExtractionPattern - .firstMatch(signingIdentity) - ?.group(1); - - // If `security`'s output format changes, we'd have to update the above regex. - if (signingCertificateId == null) { - return null; - } - - String signingCertificateStdout; - try { - signingCertificateStdout = - (await processUtils.run([ - 'security', - 'find-certificate', - '-c', - signingCertificateId, - '-p', - ], throwOnError: true)).stdout.trim(); - } on ProcessException catch (error) { - logger.printTrace("Couldn't find the certificate: $error."); - return null; - } - - final Process opensslProcess = await processUtils.start(const [ - 'openssl', - 'x509', - '-subject', - ]); - - await ProcessUtils.writeToStdinGuarded( - stdin: opensslProcess.stdin, - content: signingCertificateStdout, - onError: (Object? error, _) { - throw Exception('Unexpected error when writing to openssl: $error'); - }, + final Map? buildSettings = await settings._getCodeSigningBuildSettings( + shouldExitOnNoCerts: false, + automaticCodeSignStyleOnly: true, ); - await opensslProcess.stdin.close(); - final String opensslOutput = await utf8.decodeStream(opensslProcess.stdout); - // Fire and forget discard of the stderr stream so we don't hold onto resources. - // Don't care about the result. - unawaited(opensslProcess.stderr.drain()); - - if (await opensslProcess.exitCode != 0) { - return null; - } - - return _certificateOrganizationalUnitExtractionPattern.firstMatch(opensslOutput)?.group(1); + return buildSettings?[_developmentTeamBuildSettingName]; } -/// Set [shouldExitOnNoCerts] to show instructions for how to add a cert when none are found, then [toolExit]. -Future _chooseSigningIdentity( - List validCodeSigningIdentities, - Logger logger, - Config config, - Terminal terminal, - bool shouldExitOnNoCerts, -) async { - // The user has no valid code signing identities. - if (validCodeSigningIdentities.isEmpty) { - if (shouldExitOnNoCerts) { - logger.printError(noCertificatesInstruction, emphasis: true); - throwToolExit('No development certificates available to code sign app for device deployment'); - } else { +class XcodeCodeSigningSettings { + XcodeCodeSigningSettings({ + required Config config, + required Logger logger, + required Platform platform, + required ProcessUtils processUtils, + required FileSystem fileSystem, + required FileSystemUtils fileSystemUtils, + required Terminal terminal, + required PlistParser plistParser, + }) : _config = config, + _logger = logger, + _platform = platform, + _processUtils = processUtils, + _fileSystem = fileSystem, + _fileSystemUtils = fileSystemUtils, + _plistParser = plistParser, + _terminal = terminal; + + final Config _config; + final Logger _logger; + final Platform _platform; + final ProcessUtils _processUtils; + final FileSystem _fileSystem; + final FileSystemUtils _fileSystemUtils; + final Terminal _terminal; + final PlistParser _plistParser; + + /// Config key for saved code-signing identity. A code-signing identity is a + /// combination of a certificate and the private key that matches the public + /// key in that certificate. + /// + /// Example: Apple Development: My Name (ABC1234EFG) + static const String kConfigCodeSignCertificate = 'ios-signing-cert'; + + /// Config key for saved provisioning profile file path. A provisioning profile + /// sets criteria for who is allowed to sign code, what apps are allowed to be + /// signed, where and when those apps can be run and how those apps are entitled. + /// + /// Example: ~/Library/Developer/Xcode/UserData/Provisioning Profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision + static const String kConfigCodeSignProvisioningProfile = 'ios-signing-profile'; + + /// Reset both [kConfigCodeSignCertificate] and [kConfigCodeSignProvisioningProfile] + /// config settings. + static void resetSettings(Config config, Logger logger) { + config.removeValue(kConfigCodeSignCertificate); + logger.printStatus('Removing "$kConfigCodeSignCertificate" value.'); + + config.removeValue(kConfigCodeSignProvisioningProfile); + logger.printStatus('Removing "$kConfigCodeSignProvisioningProfile" value.'); + } + + /// Get and validate code-sign settings from the config (provisioning profile + /// or identity) or prompt the user to select a code-signing identity for + /// automatic code-signing. + /// + /// If a provisioning profile is saved, return settings with `CODE_SIGN_STYLE=Manual`, + /// `DEVELOPMENT_TEAM`, and `PROVISIONING_PROFILE_SPECIFIER`. If the profile + /// is no longer valid, return null. + /// + /// If a identity is saved, return settings with `DEVELOPMENT_TEAM`. If no + /// settings are found or if the saved identity is no longer valid, prompt + /// the user to select a code-signing identity for automatic code-signing. + /// + /// If [shouldExitOnNoCerts] is true, throw a toolExit if no code-signing + /// identities are found. + /// + /// If [automaticCodeSignStyleOnly] is true, return null if a provisioning + /// profile is saved in the config. + Future?> _getCodeSigningBuildSettings({ + bool shouldExitOnNoCerts = true, + bool automaticCodeSignStyleOnly = false, + }) async { + if (!_platform.isMacOS) { + _logger.printTrace('Unable to get code-sign settings on non-Mac platform.'); + return null; + } + final bool toolsValidated = await _validateCodeSignSearchTools(); + if (!toolsValidated) { return null; } - } - if (validCodeSigningIdentities.length == 1) { - return validCodeSigningIdentities.first; - } - - if (validCodeSigningIdentities.length > 1) { - final String? savedCertChoice = config.getValue('ios-signing-cert') as String?; - - if (savedCertChoice != null) { - if (validCodeSigningIdentities.contains(savedCertChoice)) { - logger.printStatus( - 'Found saved certificate choice "$savedCertChoice". To clear, use "flutter config".', + final List validCodeSigningIdentities = await _getSigningIdentities(); + if (validCodeSigningIdentities.isEmpty) { + if (shouldExitOnNoCerts) { + _logger.printError(noCertificatesInstruction, emphasis: true); + throwToolExit( + 'No development certificates available to code sign app for device deployment', ); - return savedCertChoice; } else { - logger.printError( - 'Saved signing certificate "$savedCertChoice" is not a valid development certificate', + _logger.printTrace( + 'No development certificates available to code sign app for device deployment', + ); + return null; + } + } + + final String? savedProfile = + _config.getValue(XcodeCodeSigningSettings.kConfigCodeSignProvisioningProfile) as String?; + + if (savedProfile != null) { + // Provisioning profile should be used for manual signing. + if (automaticCodeSignStyleOnly) { + return null; + } + final _ProvisioningProfile? validatedProfile = await _validateSavedProfile( + savedProfile, + validCodeSigningIdentities, + ); + if (validatedProfile == null) { + return null; + } + _logger.printStatus( + 'Provisioning profile "${validatedProfile.name}" selected for iOS code signing', + ); + return { + _codeSignStyleBuildSettingName: _CodeSigningStyle.manual.label, + _developmentTeamBuildSettingName: validatedProfile.teamIdentifier, + _provisioningProfileSpecifierBuildSettingName: validatedProfile.name, + }; + } + + final String? savedCertChoice = + _config.getValue(XcodeCodeSigningSettings.kConfigCodeSignCertificate) as String?; + + String? identity; + if (savedCertChoice != null) { + identity = _validateSavedIdentity(savedCertChoice, validCodeSigningIdentities); + if (identity == null) { + _logger.printError( + 'Saved signing certificate "$savedCertChoice" is not a valid development ' + 'certificate. To clear, use "flutter config --clear-ios-signing-settings"', ); } } - // If terminal UI can't be used, just attempt with the first valid certificate - // since we can't ask the user. - if (!terminal.usesTerminalUi) { - return validCodeSigningIdentities.first; + if (identity == null) { + identity = await _selectSigningIdentity( + validCodeSigningIdentities, + autoSelectSingle: true, + throwOnCancel: true, + ); + if (identity == null) { + return null; + } } - final int count = validCodeSigningIdentities.length; - logger.printStatus( - 'Multiple valid development certificates available (your choice will be saved):', - emphasis: true, - ); - for (int i = 0; i < count; i++) { - logger.printStatus(' ${i + 1}) ${validCodeSigningIdentities[i]}', emphasis: true); + final String? developmentTeam = await _getDevelopmentTeamFromIdentity(identity); + if (developmentTeam == null) { + return null; } - logger.printStatus(' a) Abort', emphasis: true); + _logger.printStatus('Developer identity "$identity" selected for iOS code signing'); + return {_developmentTeamBuildSettingName: developmentTeam}; + } - final String choice = await terminal.promptForCharInput( - List.generate(count, (int number) => '${number + 1}')..add('a'), - prompt: 'Please select a certificate for code signing', - defaultChoiceIndex: 0, // Just pressing enter chooses the first one. - logger: logger, + void _saveCodeSignIdentity(String identity) { + _logger.printStatus('Certificate choice "$identity" saved.'); + _config.setValue(kConfigCodeSignCertificate, identity); + } + + void _saveProvisioningProfile(_ProvisioningProfile profile) { + _logger.printStatus('Provisioning Profile "${profile.name}" saved.'); + _config.setValue(kConfigCodeSignProvisioningProfile, profile.filePath); + } + + /// Validates that command-line tools `security` and `openssl` are available. + Future _validateCodeSignSearchTools({bool printError = false}) async { + // If the user's environment is missing the tools needed to find and read + // certificates, abandon. Tools should be pre-equipped on macOS. + if (!await _processUtils.exitsHappy(const ['which', 'security']) || + !await _processUtils.exitsHappy(const ['which', 'openssl'])) { + if (printError) { + _logger.printError('Unable to validate code-signing tools `security` and/or `openssl`.'); + } else { + _logger.printTrace('Unable to validate code-signing tools `security` and/or `openssl`.'); + } + return false; + } + return true; + } + + /// Get list of code-signing identities. + Future> _getSigningIdentities() async { + String findIdentityStdout; + try { + findIdentityStdout = + (await _processUtils.run([ + 'security', + 'find-identity', + '-p', + 'codesigning', + '-v', + ], throwOnError: true)).stdout.trim(); + } on ProcessException catch (error) { + _logger.printError('Unexpected failure from find-identity: $error.'); + return []; + } + + return findIdentityStdout + .split('\n') + .map((String outputLine) { + return _securityFindIdentityDeveloperIdentityExtractionPattern + .firstMatch(outputLine) + ?.group(1); + }) + .where(_isNotEmpty) + .whereType() + .toSet() // Unique. + .toList(); + } + + /// Validates the saved provisioning profile still exists and that there is a + /// valid identity/certificate for the profile. + /// + /// Returns null if profile cannot be found, parsed, or validated. + Future<_ProvisioningProfile?> _validateSavedProfile( + String savedProfilePath, + List validCodeSigningIdentities, + ) async { + final File savedProfile = _fileSystem.file(savedProfilePath); + if (!savedProfile.existsSync()) { + _logger.printError('Unable to find saved provisioning profile $savedProfilePath'); + return null; + } + final _ProvisioningProfile? parsedProfile = await _parseProvisioningProfile(savedProfile); + if (parsedProfile == null) { + return null; + } + for (final File cert in parsedProfile.developerCertificates) { + final String? identity = await _validateIdentityFromCert(cert, validCodeSigningIdentities); + if (identity != null) { + return parsedProfile; + } + } + _logger.printError( + 'Unable to find a valid certificate matching the provisioning profile $savedProfilePath', ); + return null; + } - if (choice == 'a') { - throwToolExit('Aborted. Code signing is required to build a deployable iOS app.'); - } else { - final String selectedCert = validCodeSigningIdentities[int.parse(choice) - 1]; - logger.printStatus('Certificate choice "$selectedCert" saved'); - config.setValue('ios-signing-cert', selectedCert); - return selectedCert; + /// Decode and convert a .mobileprovision file to a .plist file and then + /// parse the .plist into [_ProvisioningProfile]. + Future<_ProvisioningProfile?> _parseProvisioningProfile(File provisioningProfileFile) async { + final Directory profilesDirectory = _fileSystem.systemTempDirectory.childDirectory( + 'provisioning_profiles', + ); + profilesDirectory.createSync(recursive: true); + final File decodedProfile = profilesDirectory.childFile( + 'decoded_profile_${provisioningProfileFile.basename}.plist', + ); + try { + await _processUtils.run([ + 'security', + 'cms', + '-D', + '-i', + provisioningProfileFile.path, + '-o', + decodedProfile.path, + ], throwOnError: true); + } on ProcessException catch (error) { + _logger.printError('Unexpected failure from security: $error.'); + return null; + } + if (!decodedProfile.existsSync()) { + _logger.printError('Failed to decode ${provisioningProfileFile.basename}'); + return null; + } + try { + final Map contents = _plistParser.parseFile(decodedProfile.path); + return _ProvisioningProfile.fromPlist( + provisioningProfileFile.path, + contents, + fileSystem: _fileSystem, + ); + } on Exception catch (e) { + _logger.printError('Failed to parse provisioning profile: $e'); + return null; } } - return null; + /// Extract the Common Name from the [certificate] and then search for + /// matching identities in [validCodeSigningIdentities]. Return the first + /// matching. + Future _validateIdentityFromCert( + File certificate, + List validCodeSigningIdentities, + ) async { + final String resultsStdOut; + try { + final RunResult results = await _processUtils.run([ + 'openssl', + 'x509', + '-subject', + '-in', + certificate.path, + '-inform', + 'DER', + ], throwOnError: true); + resultsStdOut = results.stdout; + } on ProcessException catch (error) { + _logger.printError('Unexpected failure from openssl: $error.'); + return null; + } + + final String? commonName = _certificateCommonNameExtractionPattern + .firstMatch(resultsStdOut) + ?.group(1); + if (commonName == null) { + _logger.printError('Unable to extract Common Name from certificate.'); + return null; + } + return validCodeSigningIdentities.where((String id) => id.contains(commonName)).firstOrNull; + } + + /// Returns [identity] if it is found within [validCodeSigningIdentities] and + /// prints a message that it was found. + String? _validateSavedIdentity(String identity, List validCodeSigningIdentities) { + if (validCodeSigningIdentities.contains(identity)) { + _logger.printStatus( + 'Found saved certificate choice "$identity". To clear, use "flutter config ' + '--clear-ios-signing-settings".', + ); + return identity; + } + return null; + } + + /// Find the certificate for the [identity] and extract the development team / + /// organizational unit from the certificate. + Future _getDevelopmentTeamFromIdentity(String identity) async { + final String? signingCertificateId = _securityFindIdentityCertificateCnExtractionPattern + .firstMatch(identity) + ?.group(1); + + // If `security`'s output format changes, we'd have to update the above regex. + if (signingCertificateId == null) { + _logger.printError('Unable to parse common name from code-signing certificate $identity'); + return null; + } + String signingCertificateStdout; + try { + signingCertificateStdout = + (await _processUtils.run([ + 'security', + 'find-certificate', + '-c', + signingCertificateId, + '-p', + ], throwOnError: true)).stdout.trim(); + } on ProcessException catch (error) { + _logger.printError('Unexpected error from security: $error'); + return null; + } + + final Process opensslProcess = await _processUtils.start(const [ + 'openssl', + 'x509', + '-subject', + ]); + + await ProcessUtils.writeToStdinGuarded( + stdin: opensslProcess.stdin, + content: signingCertificateStdout, + onError: (Object? error, _) { + throw Exception('Unexpected error when writing to openssl: $error'); + }, + ); + await opensslProcess.stdin.close(); + + final String opensslOutput = await utf8.decodeStream(opensslProcess.stdout); + // Fire and forget discard of the stderr stream so we don't hold onto resources. + // Don't care about the result. + unawaited(opensslProcess.stderr.drain()); + + if (await opensslProcess.exitCode != 0) { + _logger.printError('Failed to get subject name for code-signing certificate $identity'); + return null; + } + + final String? developmentTeam = _certificateOrganizationalUnitExtractionPattern + .firstMatch(opensslOutput) + ?.group(1); + if (developmentTeam == null) { + _logger.printError( + 'Unable to parse development team from code-signing certificate $identity', + ); + return null; + } + return developmentTeam; + } + + /// Select code-signinging settings and save to config. + /// + /// Available options include automatic signing with a code-signing identity + /// or manual code-signing with a provisioning profile. + Future selectSettings() async { + // If terminal UI can't be used, just attempt with the first valid certificate + // since we can't ask the user. + if (!_terminal.stdinHasTerminal) { + _logger.printError( + 'Unable to detect stdin for the terminal. Code-signing selection requires stdin.', + ); + return; + } + _terminal.usesTerminalUi = true; + + final bool toolsValidated = await _validateCodeSignSearchTools(printError: true); + if (!toolsValidated) { + return; + } + + final String? savedCertChoice = + _config.getValue(XcodeCodeSigningSettings.kConfigCodeSignCertificate) as String?; + final String? savedProfile = + _config.getValue(XcodeCodeSigningSettings.kConfigCodeSignProvisioningProfile) as String?; + + if (savedCertChoice != null || savedProfile != null) { + _logger.printError( + 'Code-signing settings are already set. To reset them, use "flutter config ' + '--clear-ios-signing-settings"', + ); + return; + } + + final _CodeSigningStyle? style = await _selectSigningStyle(); + if (style == null) { + _logger.printWarning(_codeSignSelectionCanceled); + return; + } + + if (style == _CodeSigningStyle.automatic) { + final List validCodeSigningIdentities = await _getSigningIdentities(); + if (validCodeSigningIdentities.isEmpty) { + _logger.printError(noCertificatesInstruction, emphasis: true); + _logger.printWarning(_codeSignSelectionCanceled); + return; + } + final String? identity = await _selectSigningIdentity(validCodeSigningIdentities); + if (identity == null) { + _logger.printWarning(_codeSignSelectionCanceled); + return; + } + } else if (style == _CodeSigningStyle.manual) { + final List<_ProvisioningProfile> validProvisioningProfiles = await _getProvisioningProfiles(); + if (validProvisioningProfiles.isEmpty) { + _logger.printError( + 'No provisioning profiles were found. To learn how to create or download ' + 'a provisioning profile, please see ' + 'https://developer.apple.com/help/account/manage-provisioning-profiles/create-a-development-provisioning-profile', + emphasis: true, + ); + _logger.printWarning(_codeSignSelectionCanceled); + return; + } + final _ProvisioningProfile? profile = await _selectProvisioningProfile( + validProvisioningProfiles, + ); + if (profile == null) { + _logger.printWarning(_codeSignSelectionCanceled); + return; + } + _saveProvisioningProfile(profile); + } + } + + /// Prompt user to select a code-signing style (Automatic or Manual). + Future<_CodeSigningStyle?> _selectSigningStyle() async { + _logger.printStatus('Code Signing Styles:', emphasis: true); + _logger.printStatus( + ' This setting specifies the method used to acquire and locate signing ' + 'assets. Choose Automatic to let Xcode automatically create and update ' + 'profiles, app IDs, and certificates. Choose Manual to create and update ' + 'these yourself on the developer website.', + ); + _logger.printStatus('[1]: ${_CodeSigningStyle.automatic.label} (recommended)'); + _logger.printStatus('[2]: ${_CodeSigningStyle.manual.label}'); + final String choice = await _terminal.promptForCharInput( + ['1', '2', 'q'], + prompt: 'Select a signing style (or "q" to quit)', + defaultChoiceIndex: 0, // Just pressing enter chooses the first one. + logger: _logger, + displayAcceptedCharacters: false, + ); + return switch (choice) { + '1' => _CodeSigningStyle.automatic, + '2' => _CodeSigningStyle.manual, + _ => null, + }; + } + + /// Prompts the user to select a code-signing identity from a list of [validCodeSigningIdentities]. + /// Selects the first one found without prompting if there is no stdin or if + /// [autoSelectSingle] is true and only one identity was found. + /// + /// Saves the selected identity to the config. Does not save if auto-selected. + /// + /// Throw an error if [throwOnCancel] is true and the user quits while + /// selecting an identity. + Future _selectSigningIdentity( + List validCodeSigningIdentities, { + bool autoSelectSingle = false, + bool throwOnCancel = false, + }) async { + if (validCodeSigningIdentities.isEmpty) { + return null; + } + + if (autoSelectSingle && validCodeSigningIdentities.length == 1) { + return validCodeSigningIdentities.first; + } + + // If terminal UI can't be used, just attempt with the first valid certificate + // since we can't ask the user. + if (!_terminal.stdinHasTerminal) { + return validCodeSigningIdentities.first; + } + _terminal.usesTerminalUi = true; + + _logger.printStatus( + '\nValid development certificates available (your choice will be saved):', + emphasis: true, + ); + final int count = validCodeSigningIdentities.length; + for (int i = 0; i < count; i++) { + _logger.printStatus('[${i + 1}] ${validCodeSigningIdentities[i]}'); + } + final String choice = await _terminal.promptForCharInput( + List.generate(count, (int number) => '${number + 1}')..add('q'), + prompt: 'Please select a certificate for code signing (or "q" to quit)', + defaultChoiceIndex: 0, // Just pressing enter chooses the first one. + logger: _logger, + displayAcceptedCharacters: false, + ); + if (choice == 'q') { + if (throwOnCancel) { + throwToolExit( + 'No certificate was selected. Code signing is required to build a deployable iOS app.', + ); + } else { + return null; + } + } + final String selectedCert = validCodeSigningIdentities[int.parse(choice) - 1]; + + _saveCodeSignIdentity(selectedCert); + + return selectedCert; + } + + /// Get list of provisioning profiles from `~/Library/Developer/Xcode/UserData/Provisioning\ Profiles`. + /// + /// Only return non-Xcode-managed profiles with matching valid identities. + Future> _getProvisioningProfiles() async { + final String? homeDir = _fileSystemUtils.homeDirPath; + if (homeDir == null) { + return <_ProvisioningProfile>[]; + } + final Directory profileDirectory = _fileSystem.directory( + _fileSystem.path.join( + homeDir, + 'Library', + 'Developer', + 'Xcode', + 'UserData', + 'Provisioning Profiles', + ), + ); + + if (!profileDirectory.existsSync()) { + return <_ProvisioningProfile>[]; + } + + final List validCodeSigningIdentities = await _getSigningIdentities(); + + final List<_ProvisioningProfile> profiles = <_ProvisioningProfile>[]; + for (final FileSystemEntity entity in profileDirectory.listSync()) { + if (entity is! File || _fileSystem.path.extension(entity.path) != '.mobileprovision') { + continue; + } + final _ProvisioningProfile? profile = await _parseProvisioningProfile(entity); + + // Xcode managed profiles can't be used for manual code-signing. + final bool? isXcodeManaged = profile?.isXcodeManaged; + if (profile == null || (isXcodeManaged != null && isXcodeManaged)) { + continue; + } + + // Only list profiles with valid identities. + for (final File cert in profile.developerCertificates) { + if (await _validateIdentityFromCert(cert, validCodeSigningIdentities) != null) { + profiles.add(profile); + break; + } + } + } + return profiles; + } + + /// Prompt the user to select from list of [validatedProfiles]. + Future<_ProvisioningProfile?> _selectProvisioningProfile( + List<_ProvisioningProfile> validatedProfiles, + ) async { + if (validatedProfiles.isEmpty) { + return null; + } + + _logger.printStatus( + '\nValid provisioning profiles available (your choice will be saved):', + emphasis: true, + ); + int count = 1; + for (final _ProvisioningProfile profile in validatedProfiles) { + _logger.printStatus( + '[$count]: ${profile.name} (${profile.teamIdentifier}) | Expires ${profile.expirationDate}', + ); + count++; + } + + _logger.printStatus('[$count]: Other (not listed)'); + final String choice = await _terminal.promptForCharInput( + List.generate(validatedProfiles.length + 1, (int number) => '${number + 1}') + ..add('q'), + prompt: 'Select a provisioning profile (or "q" to quit)', + defaultChoiceIndex: 0, // Just pressing enter chooses the first one. + logger: _logger, + displayAcceptedCharacters: false, + ); + if (choice == 'q') { + return null; + } else if (choice == '$count') { + _logger.printError( + 'If you have already downloaded a provisioning profile, double-click it ' + 'in Finder to install it. To learn how to create or download a ' + 'provisioning profile, please see ' + 'https://developer.apple.com/help/account/manage-provisioning-profiles/create-a-development-provisioning-profile', + ); + return null; + } + + return validatedProfiles[int.parse(choice) - 1]; + } +} + +enum _CodeSigningStyle { + automatic('Automatic'), + manual('Manual'); + + const _CodeSigningStyle(this.label); + final String label; +} + +class _ProvisioningProfile { + _ProvisioningProfile({ + required this.filePath, + required this.name, + required this.teamIdentifier, + required this.expirationDate, + required this.developerCertificates, + this.isXcodeManaged, + }); + + factory _ProvisioningProfile.fromPlist( + String filePath, + Map data, { + required FileSystem fileSystem, + }) { + final String? name = data['Name']?.toString(); + if (name == null) { + throw Exception('Unable to parse Name value for provisioning profile.'); + } + + List identifiers = []; + if (data case {'TeamIdentifier': final List values}) { + try { + identifiers = List.from(values); + if (identifiers.isEmpty) { + throw Exception('Unable to parse TeamIdentifier value for provisioning profile.'); + } + } on TypeError { + throw Exception('Error parsing TeamIdentifier value: $values'); + } + } + + final String? uuid = data['UUID']?.toString(); + if (uuid == null) { + throw Exception('Unable to parse UUID value for provisioning profile.'); + } + + final List certificateFiles = []; + if (data case {'DeveloperCertificates': final List values}) { + for (int i = 0; i < values.length; i++) { + final Object? obj = values[i]; + if (obj != null && obj is List) { + final File certFile = fileSystem.systemTempDirectory + .childDirectory('provisioning_profile_certificates') + .childFile('${uuid}_$i.cer'); + certFile.createSync(recursive: true); + certFile.writeAsBytesSync(obj); + certificateFiles.add(certFile); + } + } + } + if (certificateFiles.isEmpty) { + throw Exception('Unable to parse DeveloperCertificates value for provisioning profile.'); + } + + final String? expirationDateString = data['ExpirationDate']?.toString(); + if (expirationDateString == null) { + throw Exception('Unable to parse ExpirationDate value for provisioning profile.'); + } + final DateTime expirationDate = DateTime.parse(expirationDateString); + + return _ProvisioningProfile( + filePath: filePath, + name: name, + developerCertificates: certificateFiles, + isXcodeManaged: data['IsXcodeManaged'] is bool? ? data['IsXcodeManaged'] as bool? : null, + expirationDate: expirationDate, + teamIdentifier: identifiers.first, + ); + } + + final String filePath; + final String name; + final String teamIdentifier; + final DateTime expirationDate; + final List developerCertificates; + final bool? isXcodeManaged; } /// Returns true if s is a not empty string. diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 7644fd8e91..86ab80745c 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -281,6 +281,9 @@ Future buildXcodeProject({ logger: globals.logger, config: globals.config, terminal: globals.terminal, + fileSystem: globals.fs, + fileSystemUtils: globals.fsUtils, + plistParser: globals.plistParser, ); } diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index 858af002c4..04ffc0898c 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -893,6 +893,9 @@ def __lldb_init_module(debugger: lldb.SBDebugger, _): logger: globals.logger, config: globals.config, terminal: globals.terminal, + fileSystem: globals.fs, + fileSystemUtils: globals.fsUtils, + plistParser: globals.plistParser, ); final String projectName = parent.manifest.appName; diff --git a/packages/flutter_tools/test/general.shard/ios/code_signing_test.dart b/packages/flutter_tools/test/general.shard/ios/code_signing_test.dart index afec2fd6c6..f461440329 100644 --- a/packages/flutter_tools/test/general.shard/ios/code_signing_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/code_signing_test.dart @@ -5,13 +5,16 @@ import 'dart:async'; import 'dart:convert'; +import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/config.dart'; -import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/terminal.dart'; -import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/ios/code_signing.dart'; +import 'package:flutter_tools/src/ios/plist_parser.dart'; +import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/fake_process_manager.dart'; @@ -25,134 +28,181 @@ const String kCertificates = ''' void main() { group('Auto signing', () { - late Config testConfig; - late AnsiTerminal testTerminal; - late BufferLogger logger; - late Platform macosPlatform; + group('with getCodeSigningIdentityDevelopmentTeamBuildSetting', () { + testWithoutContext('No auto-sign if Xcode project settings are not available', () async { + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: FakeProcessManager.empty(), + platform: FakePlatform(operatingSystem: 'macos'), + logger: BufferLogger.test(), + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + expect(signingConfigs, isNull); + }); - setUp(() async { - logger = BufferLogger.test(); - testConfig = Config.test(); - testTerminal = TestTerminal(); - testTerminal.usesTerminalUi = true; - macosPlatform = FakePlatform(operatingSystem: 'macos'); - }); + testWithoutContext('No discovery if development team specified in Xcode project', () async { + final BufferLogger logger = BufferLogger.test(); + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {'DEVELOPMENT_TEAM': 'abc'}, + platform: FakePlatform(operatingSystem: 'macos'), + processManager: FakeProcessManager.empty(), + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + expect(signingConfigs, isNull); + expect( + logger.statusText, + equals( + 'Automatically signing iOS for device deployment using specified development team in Xcode project: abc\n', + ), + ); + expect(logger.errorText, isEmpty); + expect(logger.traceText, isEmpty); + }); - testWithoutContext('No auto-sign if Xcode project settings are not available', () async { - final Map? signingConfigs = - await getCodeSigningIdentityDevelopmentTeamBuildSetting( - buildSettings: {}, - processManager: FakeProcessManager.empty(), - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, + testWithoutContext( + 'No discovery if provisioning profile specified in Xcode project', + () async { + final BufferLogger logger = BufferLogger.test(); + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {'PROVISIONING_PROFILE': 'abc'}, + platform: FakePlatform(operatingSystem: 'macos'), + processManager: FakeProcessManager.empty(), + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + expect(signingConfigs, isNull); + expect(logger.errorText, isEmpty); + expect(logger.statusText, isEmpty); + expect(logger.traceText, isEmpty); + }, + ); + + testWithoutContext( + 'throws error with instructions when no valid code signing certificates', + () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + ), + ]); + final BufferLogger logger = BufferLogger.test(); + await expectLater( + () => getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + platform: FakePlatform(operatingSystem: 'macos'), + processManager: processManager, + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ), + throwsToolExit( + message: + 'No development certificates available to code sign app for device deployment', + ), ); - expect(signingConfigs, isNull); - }); - - testWithoutContext('No discovery if development team specified in Xcode project', () async { - final Map? signingConfigs = - await getCodeSigningIdentityDevelopmentTeamBuildSetting( - buildSettings: {'DEVELOPMENT_TEAM': 'abc'}, - platform: macosPlatform, - processManager: FakeProcessManager.empty(), - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - expect(signingConfigs, isNull); - expect( - logger.statusText, - equals( - 'Automatically signing iOS for device deployment using specified development team in Xcode project: abc\n', - ), - ); - }); - - testWithoutContext('No auto-sign if security or openssl not available', () async { - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security'], exitCode: 1), - ]); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - expect(developmentTeam, isNull); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('No valid code signing certificates', () async { - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - ), - ]); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, + expect(logger.errorText, contains(noCertificatesInstruction)); + }, ); - expect(developmentTeam, isNull); - expect(processManager, hasNoRemainingExpectations); - }); + testWithoutContext('No auto-sign if security or openssl not available', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security'], exitCode: 1), + ]); + final BufferLogger logger = BufferLogger.test(); - testWithoutContext('No valid code signing certificates shows instructions', () async { - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - ), - ]); + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + expect(signingConfigs, isNull); + expect(processManager, hasNoRemainingExpectations); + expect(logger.traceText, contains('Unable to validate code-signing tools')); + expect(logger.errorText, isEmpty); + }); - await expectLater( - () => getCodeSigningIdentityDevelopmentTeamBuildSetting( - buildSettings: {}, - platform: macosPlatform, - processManager: processManager, - logger: logger, - config: testConfig, - terminal: testTerminal, - ), - throwsToolExit( - message: 'No development certificates available to code sign app for device deployment', - ), - ); - }); + testWithoutContext('No valid code signing certificates on non-macOS platform', () async { + final FakeProcessManager processManager = FakeProcessManager.empty(); + final BufferLogger logger = BufferLogger.test(); - testWithoutContext('No valid code signing certificates on non-macOS platform', () async { - final FakeProcessManager processManager = FakeProcessManager.empty(); + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(), + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: FakePlatform(), - logger: logger, - config: testConfig, - terminal: testTerminal, - ); + expect(signingConfigs, isNull); + expect(processManager, hasNoRemainingExpectations); + expect(logger.traceText, contains('Unable to get code-sign settings on non-Mac platform')); + }); - expect(developmentTeam, isNull); - expect(processManager, hasNoRemainingExpectations); - }); + testWithoutContext('uses saved provisioning profile', () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); - testWithoutContext( - 'Test single identity and certificate organization development team build setting', - () async { - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + final File cert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUID1234_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Company Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUID1234', + }, + ], + ); const String certificates = ''' -1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Company Development (12ABCD234E)" 1 valid identities found'''; final FakeProcessManager processManager = FakeProcessManager.list([ const FakeCommand(command: ['which', 'security']), @@ -161,6 +211,716 @@ void main() { command: ['security', 'find-identity', '-p', 'codesigning', '-v'], stdout: certificates, ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + onRun: (List command) => profilePlist.createSync(recursive: true), + ), + FakeCommand( + command: ['openssl', 'x509', '-subject', '-in', cert.path, '-inform', 'DER'], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: plistParser, + ); + + expect(processManager, hasNoRemainingExpectations); + expect(cert.existsSync(), isTrue); + expect(cert.readAsBytesSync(), [0, 1, 2, 3]); + expect( + logger.statusText, + contains('Provisioning profile "Company Development" selected for iOS code signing'), + ); + expect(signingConfigs, { + 'CODE_SIGN_STYLE': 'Manual', + 'DEVELOPMENT_TEAM': 'ABCDE1F2DH', + 'PROVISIONING_PROFILE_SPECIFIER': 'Company Development', + }); + }); + + testWithoutContext('does not use saved provisioning profile if does not exist', () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + testConfig.setValue('ios-signing-profile', profileFilePath); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Unable to find saved provisioning profile')); + expect(signingConfigs, isNull); + }); + + testWithoutContext( + 'does not use saved provisioning profile if security fails to decode', + () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + exitCode: 1, + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Unexpected failure from security')); + expect(signingConfigs, isNull); + }, + ); + + testWithoutContext( + 'does not use saved provisioning profile if security fails to create plist', + () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Failed to decode')); + expect(signingConfigs, isNull); + }, + ); + + testWithoutContext('does not use saved provisioning profile if fails to parse plist', () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + onRun: (List command) => profilePlist.createSync(recursive: true), + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Failed to parse provisioning profile')); + expect(signingConfigs, isNull); + }); + + testWithoutContext( + 'does not uses saved provisioning profile if openssl fails to read cert', + () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); + + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + final File cert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUID1234_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Flutter Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUID1234', + }, + ], + ); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + onRun: (List command) => profilePlist.createSync(recursive: true), + ), + FakeCommand( + command: ['openssl', 'x509', '-subject', '-in', cert.path, '-inform', 'DER'], + exitCode: 1, + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: plistParser, + ); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Unexpected failure from openssl')); + expect(signingConfigs, isNull); + }, + ); + + testWithoutContext( + 'does not uses saved provisioning profile if fails to parse common name from cert', + () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); + + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + final File cert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUID1234_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Flutter Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUID1234', + }, + ], + ); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + onRun: (List command) => profilePlist.createSync(recursive: true), + ), + FakeCommand( + command: ['openssl', 'x509', '-subject', '-in', cert.path, '-inform', 'DER'], + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: plistParser, + ); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Unable to extract Common Name from certificate')); + expect(signingConfigs, isNull); + }, + ); + + testWithoutContext( + 'does not use saved provisioning profile if fails to find matching identity', + () async { + final Config testConfig = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final BufferLogger logger = BufferLogger.test(); + const String profileFilePath = + '/path/to/profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision'; + fileSystem.file(profileFilePath).createSync(recursive: true); + testConfig.setValue('ios-signing-profile', profileFilePath); + + final File profilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision.plist', + ); + final File cert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUID1234_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Flutter Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUID1234', + }, + ], + ); + const String certificates = ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Not a Match (12ABCD234E)" + 1 valid identities found'''; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileFilePath, + '-o', + profilePlist.path, + ], + onRun: (List command) => profilePlist.createSync(recursive: true), + ), + FakeCommand( + command: ['openssl', 'x509', '-subject', '-in', cert.path, '-inform', 'DER'], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + ]); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(), + plistParser: plistParser, + ); + + expect(processManager, hasNoRemainingExpectations); + expect( + logger.errorText, + contains('Unable to find a valid certificate matching the provisioning profile'), + ); + expect(signingConfigs, isNull); + }, + ); + + testWithoutContext( + 'Test single identity and certificate organization development team build setting', + () async { + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + const String certificates = ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" + 1 valid identities found'''; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', + completer: completer, + ), + ]); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + + final BufferLogger logger = BufferLogger.test(); + + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {'bogus': 'bogus'}, + platform: FakePlatform(operatingSystem: 'macos'), + processManager: processManager, + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(logger.statusText, contains('iPhone Developer: Profile 1 (1111AAAA11)')); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(signingConfigs, {'DEVELOPMENT_TEAM': '3333CCCC33'}); + expect(processManager, hasNoRemainingExpectations); + }, + ); + + testWithoutContext( + 'Test auto-select single identity and certificate organization development team', + () async { + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + const String certificates = ''' + 1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" + 1 valid identities found'''; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', + completer: completer, + ), + ]); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + final Config testConfig = Config.test(); + final BufferLogger logger = BufferLogger.test(); + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(logger.statusText, contains('iPhone Developer: Profile 1 (1111AAAA11)')); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(developmentTeam, '3333CCCC33'); + expect(testConfig.getValue('ios-signing-cert'), isNull); + expect(processManager, hasNoRemainingExpectations); + }, + ); + + testWithoutContext( + 'Test single identity (Catalina format) and certificate organization works', + () async { + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + const String certificates = ''' + 1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Profile 1 (1111AAAA11)" + 1 valid identities found'''; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', + completer: completer, + ), + ]); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + final BufferLogger logger = BufferLogger.test(); + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(logger.statusText, contains('Apple Development: Profile 1 (1111AAAA11)')); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(developmentTeam, '3333CCCC33'); + expect(processManager, hasNoRemainingExpectations); + }, + ); + + testWithoutContext('Test multiple identity and certificate organization works', () async { + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', + completer: completer, + ), + ]); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + + final BufferLogger logger = BufferLogger.test(); + final Config testConfig = Config.test(); + final FakeTerminal testTerminal = FakeTerminal(); + testTerminal.setPrompt(['1', '2', '3', 'q'], '3'); + + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: testTerminal, + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect( + logger.statusText, + contains( + 'Developer identity "iPhone Developer: Profile 3 (3333CCCC33)" selected for iOS code signing', + ), + ); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(developmentTeam, '4444DDDD44'); + expect(testConfig.getValue('ios-signing-cert'), 'iPhone Developer: Profile 3 (3333CCCC33)'); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Test auto-select from multiple identity in machine mode works', () async { + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), const FakeCommand( command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], stdout: 'This is a fake certificate', @@ -169,7 +929,67 @@ void main() { command: const ['openssl', 'x509', '-subject'], stdin: IOSink(controller.sink), stdout: - 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', + 'subject= /CN=iPhone Developer: Profile 3 (1111AAAA11)/OU=5555EEEE55/O=My Team/C=US', + completer: completer, + ), + ]); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + + final BufferLogger logger = BufferLogger.test(); + final Config testConfig = Config.test(); + + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(stdinHasTerminal: false), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect( + logger.statusText, + contains( + 'Developer identity "iPhone Developer: Profile 1 (1111AAAA11)" selected for iOS code signing', + ), + ); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(developmentTeam, '5555EEEE55'); + expect(testConfig.getValue('ios-signing-cert'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Test saved certificate used', () async { + final Config testConfig = Config.test(); + final BufferLogger logger = BufferLogger.test(); + testConfig.setValue('ios-signing-cert', 'iPhone Developer: Profile 3 (3333CCCC33)'); + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', completer: completer, ), ]); @@ -183,458 +1003,1087 @@ void main() { final Map? signingConfigs = await getCodeSigningIdentityDevelopmentTeamBuildSetting( - buildSettings: {'bogus': 'bogus'}, - platform: macosPlatform, + buildSettings: {}, processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect( + logger.statusText, + contains( + 'Found saved certificate choice "iPhone Developer: Profile 3 (3333CCCC33)". To clear, use "flutter config --clear-ios-signing-settings"', + ), + ); + expect( + logger.statusText, + contains( + 'Developer identity "iPhone Developer: Profile 3 (3333CCCC33)" selected for iOS code signing', + ), + ); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(signingConfigs, {'DEVELOPMENT_TEAM': '4444DDDD44'}); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Test invalid saved certificate shows error and prompts again', () async { + final Config testConfig = Config.test(); + testConfig.setValue('ios-signing-cert', 'iPhone Developer: Invalid Profile'); + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', + completer: completer, + ), + ]); + + final FakeTerminal testTerminal = FakeTerminal(); + testTerminal.setPrompt(['1', '2', '3', 'q'], '3'); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + final BufferLogger logger = BufferLogger.test(); + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), logger: logger, config: testConfig, terminal: testTerminal, + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), ); - expect(logger.statusText, contains('iPhone Developer: Profile 1 (1111AAAA11)')); - expect(logger.errorText, isEmpty); + expect( + logger.errorText, + containsIgnoringWhitespace( + 'Saved signing certificate "iPhone Developer: Invalid Profile" is not a valid development certificate', + ), + ); + expect( + logger.statusText, + contains('Certificate choice "iPhone Developer: Profile 3 (3333CCCC33)"'), + ); + expect(signingConfigs, {'DEVELOPMENT_TEAM': '4444DDDD44'}); expect(stdin, 'This is a fake certificate'); - expect(signingConfigs, {'DEVELOPMENT_TEAM': '3333CCCC33'}); + expect(testConfig.getValue('ios-signing-cert'), 'iPhone Developer: Profile 3 (3333CCCC33)'); expect(processManager, hasNoRemainingExpectations); - }, - ); + }); - testWithoutContext( - 'Test single identity and certificate organization development team', - () async { - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); - const String certificates = ''' -1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" - 1 valid identities found'''; + testWithoutContext('find-identity failure', () async { final FakeProcessManager processManager = FakeProcessManager.list([ const FakeCommand(command: ['which', 'security']), const FakeCommand(command: ['which', 'openssl']), const FakeCommand( command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: certificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(controller.sink), - stdout: - 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', - completer: completer, + exitCode: 1, ), ]); - // Verify that certificate value is passed into openssl command. - String? stdin; - controller.stream.listen((List chunk) { - stdin = utf8.decode(chunk); - completer.complete(); - }); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - - expect(logger.statusText, contains('iPhone Developer: Profile 1 (1111AAAA11)')); - expect(logger.errorText, isEmpty); - expect(stdin, 'This is a fake certificate'); - expect(developmentTeam, '3333CCCC33'); - expect(processManager, hasNoRemainingExpectations); - }, - ); - - testWithoutContext( - 'Test single identity (Catalina format) and certificate organization works', - () async { - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); - const String certificates = ''' -1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Profile 1 (1111AAAA11)" - 1 valid identities found'''; - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: certificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(controller.sink), - stdout: - 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', - completer: completer, - ), - ]); - - // Verify that certificate value is passed into openssl command. - String? stdin; - controller.stream.listen((List chunk) { - stdin = utf8.decode(chunk); - completer.complete(); - }); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - - expect(logger.statusText, contains('Apple Development: Profile 1 (1111AAAA11)')); - expect(logger.errorText, isEmpty); - expect(stdin, 'This is a fake certificate'); - expect(developmentTeam, '3333CCCC33'); - expect(processManager, hasNoRemainingExpectations); - }, - ); - - testWithoutContext('Test multiple identity and certificate organization works', () async { - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); - mockTerminalStdInStream = Stream.value('3'); - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: kCertificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(controller.sink), - stdout: - 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', - completer: completer, - ), - ]); - - // Verify that certificate value is passed into openssl command. - String? stdin; - controller.stream.listen((List chunk) { - stdin = utf8.decode(chunk); - completer.complete(); - }); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - - expect( - logger.statusText, - contains('Please select a certificate for code signing [1|2|3|a]: 3'), - ); - expect( - logger.statusText, - contains( - 'Developer identity "iPhone Developer: Profile 3 (3333CCCC33)" selected for iOS code signing', - ), - ); - expect(logger.errorText, isEmpty); - expect(stdin, 'This is a fake certificate'); - expect(developmentTeam, '4444DDDD44'); - expect(testConfig.getValue('ios-signing-cert'), 'iPhone Developer: Profile 3 (3333CCCC33)'); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('Test multiple identity in machine mode works', () async { - testTerminal.usesTerminalUi = false; - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: kCertificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(controller.sink), - stdout: - 'subject= /CN=iPhone Developer: Profile 3 (1111AAAA11)/OU=5555EEEE55/O=My Team/C=US', - completer: completer, - ), - ]); - - // Verify that certificate value is passed into openssl command. - String? stdin; - controller.stream.listen((List chunk) { - stdin = utf8.decode(chunk); - completer.complete(); - }); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - - expect( - logger.statusText, - contains( - 'Developer identity "iPhone Developer: Profile 1 (1111AAAA11)" selected for iOS code signing', - ), - ); - expect(logger.errorText, isEmpty); - expect(stdin, 'This is a fake certificate'); - expect(developmentTeam, '5555EEEE55'); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('Test saved certificate used', () async { - testConfig.setValue('ios-signing-cert', 'iPhone Developer: Profile 3 (3333CCCC33)'); - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: kCertificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(controller.sink), - stdout: - 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', - completer: completer, - ), - ]); - - // Verify that certificate value is passed into openssl command. - String? stdin; - controller.stream.listen((List chunk) { - stdin = utf8.decode(chunk); - completer.complete(); - }); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - - expect( - logger.statusText, - contains( - 'Found saved certificate choice "iPhone Developer: Profile 3 (3333CCCC33)". To clear, use "flutter config"', - ), - ); - expect( - logger.statusText, - contains( - 'Developer identity "iPhone Developer: Profile 3 (3333CCCC33)" selected for iOS code signing', - ), - ); - expect(logger.errorText, isEmpty); - expect(stdin, 'This is a fake certificate'); - expect(developmentTeam, '4444DDDD44'); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('Test invalid saved certificate shows error and prompts again', () async { - testConfig.setValue('ios-signing-cert', 'iPhone Developer: Invalid Profile'); - mockTerminalStdInStream = Stream.value('3'); - final Completer completer = Completer(); - final StreamController> controller = StreamController>(); - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: kCertificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(controller.sink), - stdout: - 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', - completer: completer, - ), - ]); - - // Verify that certificate value is passed into openssl command. - String? stdin; - controller.stream.listen((List chunk) { - stdin = utf8.decode(chunk); - completer.complete(); - }); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - - expect( - logger.errorText, - containsIgnoringWhitespace( - 'Saved signing certificate "iPhone Developer: Invalid Profile" is not a valid development certificate', - ), - ); - expect( - logger.statusText, - contains('Certificate choice "iPhone Developer: Profile 3 (3333CCCC33)"'), - ); - expect(developmentTeam, '4444DDDD44'); - expect(stdin, 'This is a fake certificate'); - expect(testConfig.getValue('ios-signing-cert'), 'iPhone Developer: Profile 3 (3333CCCC33)'); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('find-identity failure', () async { - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - exitCode: 1, - ), - ]); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - expect(developmentTeam, isNull); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('find-certificate failure', () async { - mockTerminalStdInStream = Stream.value('3'); - - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: kCertificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], - exitCode: 1, - ), - ]); - - final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: processManager, - platform: macosPlatform, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); - expect(developmentTeam, isNull); - expect(processManager, hasNoRemainingExpectations); - }); - - testWithoutContext('handles stdin pipe breaking on openssl process', () async { - final StreamSink> stdinSink = ClosedStdinController(); - - final Completer completer = Completer(); - const String certificates = ''' -1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" - 1 valid identities found'''; - final FakeProcessManager processManager = FakeProcessManager.list([ - const FakeCommand(command: ['which', 'security']), - const FakeCommand(command: ['which', 'openssl']), - const FakeCommand( - command: ['security', 'find-identity', '-p', 'codesigning', '-v'], - stdout: certificates, - ), - const FakeCommand( - command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], - stdout: 'This is a fake certificate', - ), - FakeCommand( - command: const ['openssl', 'x509', '-subject'], - stdin: IOSink(stdinSink), - stdout: - 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', - completer: completer, - ), - ]); - - Future?> getCodeSigningIdentities() => - getCodeSigningIdentityDevelopmentTeamBuildSetting( - buildSettings: const {'bogus': 'bogus'}, - platform: macosPlatform, + await expectLater( + () => getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + platform: FakePlatform(operatingSystem: 'macos'), processManager: processManager, - logger: logger, - config: testConfig, - terminal: testTerminal, - ); + logger: BufferLogger.test(), + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ), + throwsToolExit( + message: 'No development certificates available to code sign app for device deployment', + ), + ); + expect(processManager, hasNoRemainingExpectations); + }); - await expectLater( - () => getCodeSigningIdentities(), - throwsA( - const TypeMatcher().having( - (Exception e) => e.toString(), - 'message', - equals( - 'Exception: Unexpected error when writing to openssl: SocketException: Bad pipe', + testWithoutContext('find-certificate failure', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], + exitCode: 1, + ), + ]); + + final FakeTerminal testTerminal = FakeTerminal(); + testTerminal.setPrompt(['1', '2', '3', 'q'], '3'); + final BufferLogger logger = BufferLogger.test(); + final Map? signingConfigs = + await getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: {}, + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: Config.test(), + terminal: testTerminal, + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + expect(signingConfigs, isNull); + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Unexpected error from security')); + }); + + testWithoutContext('handles stdin pipe breaking on openssl process', () async { + final StreamSink> stdinSink = ClosedStdinController(); + + final Completer completer = Completer(); + const String certificates = ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" + 1 valid identities found'''; + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '1111AAAA11', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(stdinSink), + stdout: + 'subject= /CN=iPhone Developer: Profile 1 (1111AAAA11)/OU=3333CCCC33/O=My Team/C=US', + completer: completer, + ), + ]); + + Future?> getCodeSigningIdentities() => + getCodeSigningIdentityDevelopmentTeamBuildSetting( + buildSettings: const {'bogus': 'bogus'}, + platform: FakePlatform(operatingSystem: 'macos'), + processManager: processManager, + logger: BufferLogger.test(), + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + await expectLater( + () => getCodeSigningIdentities(), + throwsA( + const TypeMatcher().having( + (Exception e) => e.toString(), + 'message', + equals( + 'Exception: Unexpected error when writing to openssl: SocketException: Bad pipe', + ), ), ), - ), + ); + }); + }); + + group('with getCodeSigningIdentityDevelopmentTeam', () { + testWithoutContext('does not error when no valid code signing certificates', () async { + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + ), + ]); + + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: BufferLogger.test(), + config: Config.test(), + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(developmentTeam, isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('does not use saved provisioning profile', () async { + final Config testConfig = Config.test(); + testConfig.setValue( + 'ios-signing-profile', + 'Profiles/1234567a-bcde-89f0-1234-g56hi567j8kl.mobileprovision', + ); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + ]); + + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: BufferLogger.test(), + config: testConfig, + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect(developmentTeam, isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('Test saved certificate used', () async { + final Config testConfig = Config.test(); + final BufferLogger logger = BufferLogger.test(); + testConfig.setValue('ios-signing-cert', 'iPhone Developer: Profile 3 (3333CCCC33)'); + final Completer completer = Completer(); + final StreamController> controller = StreamController>(); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + const FakeCommand( + command: ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], + stdout: 'This is a fake certificate', + ), + FakeCommand( + command: const ['openssl', 'x509', '-subject'], + stdin: IOSink(controller.sink), + stdout: + 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US', + completer: completer, + ), + ]); + + // Verify that certificate value is passed into openssl command. + String? stdin; + controller.stream.listen((List chunk) { + stdin = utf8.decode(chunk); + completer.complete(); + }); + + final String? developmentTeam = await getCodeSigningIdentityDevelopmentTeam( + processManager: processManager, + platform: FakePlatform(operatingSystem: 'macos'), + logger: logger, + config: testConfig, + terminal: FakeTerminal(), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + plistParser: FakePlistParser(), + ); + + expect( + logger.statusText, + contains( + 'Found saved certificate choice "iPhone Developer: Profile 3 (3333CCCC33)". To clear, use "flutter config --clear-ios-signing-settings"', + ), + ); + expect( + logger.statusText, + contains( + 'Developer identity "iPhone Developer: Profile 3 (3333CCCC33)" selected for iOS code signing', + ), + ); + expect(logger.errorText, isEmpty); + expect(stdin, 'This is a fake certificate'); + expect(developmentTeam, '4444DDDD44'); + expect(processManager, hasNoRemainingExpectations); + }); + }); + }); + + group('Select signing', () { + testWithoutContext('cancels if terminal does not have stdin', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: FakeProcessManager.empty(), logger: logger), + terminal: FakeTerminal(stdinHasTerminal: false), + plistParser: FakePlistParser(), ); + await settings.selectSettings(); + expect(logger.errorText, contains('Unable to detect stdin for the terminal')); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + }); + + testWithoutContext('cancels if code-signing tools are not found', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: FakeProcessManager.empty(), logger: logger), + terminal: FakeTerminal(), + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + expect(logger.errorText, contains('Unable to validate code-signing tools')); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + }); + + testWithoutContext('cancels if signing cert already saved', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + config.setValue('ios-signing-cert', 'some value'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + ]); + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: FakeTerminal(), + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + expect(logger.errorText, contains('Code-signing settings are already set')); + expect(config.getValue('ios-signing-cert'), 'some value'); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('cancels if signing profile already saved', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + config.setValue('ios-signing-profile', 'some value'); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + ]); + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: FakeTerminal(), + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + expect(logger.errorText, contains('Code-signing settings are already set')); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), 'some value'); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('cancels if quit while selecting code signing style', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + ]); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], 'q'); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + expect( + logger.warningText, + contains('Code-signing setup canceled. Your changes have not been saved.'), + ); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + group('with automatic code signing style', () { + testWithoutContext('cancels if no identities are found', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '1'); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + ), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + + expect(logger.errorText, contains(noCertificatesInstruction)); + expect( + logger.warningText, + contains('Code-signing setup canceled. Your changes have not been saved.'), + ); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('cancels if quit while selecting identity', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '1'); + unawaited( + terminal.promptCompleter.future.whenComplete(() { + terminal.setPrompt(['1', '2', '3', 'q'], 'q'); + }), + ); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + + expect( + logger.warningText, + contains('Code-signing setup canceled. Your changes have not been saved.'), + ); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('saves to config after selection', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '1'); + unawaited( + terminal.promptCompleter.future.whenComplete(() { + terminal.setPrompt(['1', '2', '3', 'q'], '3'); + }), + ); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: kCertificates, + ), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + + expect(logger.warningText, isEmpty); + expect(config.getValue('ios-signing-cert'), 'iPhone Developer: Profile 3 (3333CCCC33)'); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + }); + + group('with manual code signing style', () { + testWithoutContext('cancels if no profiles are found', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '2'); + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: MemoryFileSystem.test(), + fileSystemUtils: FakeFileSystemUtils(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: FakePlistParser(), + ); + await settings.selectSettings(); + + expect(logger.errorText, contains('No provisioning profiles were found')); + expect( + logger.warningText, + contains('Code-signing setup canceled. Your changes have not been saved.'), + ); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext('cancels if quit while selecting profile', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '2'); + unawaited( + terminal.promptCompleter.future.whenComplete(() { + terminal.setPrompt(['1', '2', 'q'], 'q'); + }), + ); + + const String homeDir = '/Users/username'; + + final Directory profileDirectory = fileSystem.directory( + fileSystem.path.join( + homeDir, + 'Library', + 'Developer', + 'Xcode', + 'UserData', + 'Provisioning Profiles', + ), + ); + final File validProfile = profileDirectory.childFile('profile1.mobileprovision') + ..createSync(recursive: true); + final File validProfilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_profile1.mobileprovision.plist', + ); + final File validProfileCert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUIDProfile1_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Company Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUIDProfile1', + }, + ], + ); + const String certificates = ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Company Development (12ABCD234E)" + 1 valid identities found'''; + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + validProfile.path, + '-o', + validProfilePlist.path, + ], + onRun: (List command) => validProfilePlist.createSync(recursive: true), + ), + FakeCommand( + command: [ + 'openssl', + 'x509', + '-subject', + '-in', + validProfileCert.path, + '-inform', + 'DER', + ], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(homeDirPath: homeDir), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: plistParser, + ); + await settings.selectSettings(); + + expect(logger.errorText, isEmpty); + expect( + logger.warningText, + contains('Code-signing setup canceled. Your changes have not been saved.'), + ); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }); + + testWithoutContext( + 'cancels if "Other (not listed)" selected while selecting profile', + () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '2'); + unawaited( + terminal.promptCompleter.future.whenComplete(() { + terminal.setPrompt(['1', '2', 'q'], '2'); + }), + ); + + const String homeDir = '/Users/username'; + + final Directory profileDirectory = fileSystem.directory( + fileSystem.path.join( + homeDir, + 'Library', + 'Developer', + 'Xcode', + 'UserData', + 'Provisioning Profiles', + ), + ); + final File validProfile = profileDirectory.childFile('profile1.mobileprovision') + ..createSync(recursive: true); + final File validProfilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_profile1.mobileprovision.plist', + ); + final File validProfileCert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUIDProfile1_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Company Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUIDProfile1', + }, + ], + ); + const String certificates = ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Company Development (12ABCD234E)" + 1 valid identities found'''; + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + validProfile.path, + '-o', + validProfilePlist.path, + ], + onRun: (List command) => validProfilePlist.createSync(recursive: true), + ), + FakeCommand( + command: [ + 'openssl', + 'x509', + '-subject', + '-in', + validProfileCert.path, + '-inform', + 'DER', + ], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(homeDirPath: homeDir), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: plistParser, + ); + await settings.selectSettings(); + + expect( + logger.errorText, + contains('If you have already downloaded a provisioning profile'), + ); + expect( + logger.warningText, + contains('Code-signing setup canceled. Your changes have not been saved.'), + ); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), isNull); + expect(processManager, hasNoRemainingExpectations); + }, + ); + + testWithoutContext('saves to config after selecting', () async { + final BufferLogger logger = BufferLogger.test(); + final Config config = Config.test(); + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final FakeTerminal terminal = FakeTerminal(); + terminal.setPrompt(['1', '2', 'q'], '2'); + unawaited( + terminal.promptCompleter.future.whenComplete(() { + terminal.setPrompt(['1', '2', 'q'], '1'); + }), + ); + + const String homeDir = '/Users/username'; + + final Directory profileDirectory = fileSystem.directory( + fileSystem.path.join( + homeDir, + 'Library', + 'Developer', + 'Xcode', + 'UserData', + 'Provisioning Profiles', + ), + ); + final File validProfile = profileDirectory.childFile('profile1.mobileprovision') + ..createSync(recursive: true); + final File validProfilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_profile1.mobileprovision.plist', + ); + final File validProfileInvalidCert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUIDProfile1_0.cer', + ); + final File validProfileValidCert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUIDProfile1_1.cer', + ); + + final File xcodeManagedProfile = profileDirectory.childFile('profile2.mobileprovision') + ..createSync(recursive: true); + final File xcodeManagedProfilePlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_profile2.mobileprovision.plist', + ); + + final File profileWithMissingIdentity = profileDirectory.childFile( + 'profile3.mobileprovision', + )..createSync(recursive: true); + final File profileWithMissingIdentityPlist = fileSystem.file( + '/.tmp_rand0/provisioning_profiles/decoded_profile_profile3.mobileprovision.plist', + ); + final File profileWithMissingIdentityCert = fileSystem.file( + '/.tmp_rand0/provisioning_profile_certificates/UUIDProfile3_0.cer', + ); + + final FakePlistParser plistParser = FakePlistParser( + parsedValues: >[ + { + 'Name': 'Company Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + [0, 1, 2, 3, 4], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUIDProfile1', + }, + { + 'Name': 'Company Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': true, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUIDProfile2', + }, + { + 'Name': 'Company Development', + 'ExpirationDate': '2026-02-20T16:04:31Z', + 'IsXcodeManaged': false, + 'DeveloperCertificates': >[ + [0, 1, 2, 3], + ], + 'TeamIdentifier': ['ABCDE1F2DH'], + 'UUID': 'UUIDProfile3', + }, + ], + ); + const String certificates = ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "Apple Development: Company Development (12ABCD234E)" + 1 valid identities found'''; + + final FakeProcessManager processManager = FakeProcessManager.list([ + const FakeCommand(command: ['which', 'security']), + const FakeCommand(command: ['which', 'openssl']), + const FakeCommand( + command: ['security', 'find-identity', '-p', 'codesigning', '-v'], + stdout: certificates, + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + validProfile.path, + '-o', + validProfilePlist.path, + ], + onRun: (List command) => validProfilePlist.createSync(recursive: true), + ), + FakeCommand( + command: [ + 'openssl', + 'x509', + '-subject', + '-in', + validProfileInvalidCert.path, + '-inform', + 'DER', + ], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: No matching (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + FakeCommand( + command: [ + 'openssl', + 'x509', + '-subject', + '-in', + validProfileValidCert.path, + '-inform', + 'DER', + ], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: Company Development (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + xcodeManagedProfile.path, + '-o', + xcodeManagedProfilePlist.path, + ], + onRun: (List command) => xcodeManagedProfilePlist.createSync(recursive: true), + ), + FakeCommand( + command: [ + 'security', + 'cms', + '-D', + '-i', + profileWithMissingIdentity.path, + '-o', + profileWithMissingIdentityPlist.path, + ], + onRun: + (List command) => + profileWithMissingIdentityPlist.createSync(recursive: true), + ), + FakeCommand( + command: [ + 'openssl', + 'x509', + '-subject', + '-in', + profileWithMissingIdentityCert.path, + '-inform', + 'DER', + ], + stdout: + 'subject= /UID=A123BC4D5E/CN=Apple Development: No match (12ABCD234E)/OU=ABCDE1F2DH/O=Company LLC/C=US', + ), + ]); + + final XcodeCodeSigningSettings settings = XcodeCodeSigningSettings( + config: config, + logger: logger, + platform: FakePlatform(operatingSystem: 'macos'), + fileSystem: fileSystem, + fileSystemUtils: FakeFileSystemUtils(homeDirPath: homeDir), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + terminal: terminal, + plistParser: plistParser, + ); + await settings.selectSettings(); + + expect(logger.errorText, isEmpty); + expect(logger.warningText, isEmpty); + expect(config.getValue('ios-signing-cert'), isNull); + expect(config.getValue('ios-signing-profile'), validProfile.path); + expect(processManager, hasNoRemainingExpectations); + }); }); }); } -late Stream mockTerminalStdInStream; - -class TestTerminal extends AnsiTerminal { - TestTerminal() : super(stdio: globals.stdio, platform: globals.platform); +class FakeTerminal extends Fake implements AnsiTerminal { + FakeTerminal({this.stdinHasTerminal = true, this.supportsColor = false}); @override - String bolden(String message) => '$message'; + final bool stdinHasTerminal; @override - Stream get keystrokes { - return mockTerminalStdInStream; + final bool supportsColor; + + @override + bool get isCliAnimationEnabled => supportsColor; + + @override + bool usesTerminalUi = true; + + @override + bool singleCharMode = false; + + late Completer promptCompleter; + + void setPrompt(List characters, String result) { + _nextPrompt = characters; + _nextResult = result; + promptCompleter = Completer(); } + List? _nextPrompt; + late String _nextResult; + @override - int get preferredStyle => 0; + Future promptForCharInput( + List acceptedCharacters, { + Logger? logger, + String? prompt, + int? defaultChoiceIndex, + bool displayAcceptedCharacters = true, + }) async { + expect(acceptedCharacters, _nextPrompt); + promptCompleter.complete(); + return _nextResult; + } +} + +class FakeFileSystemUtils extends Fake implements FileSystemUtils { + FakeFileSystemUtils({this.homeDirPath}); + + @override + String? homeDirPath; +} + +class FakePlistParser extends Fake implements PlistParser { + FakePlistParser({List>? parsedValues}) + : _parsedValues = parsedValues ?? >[]; + + final List> _parsedValues; + + @override + Map parseFile(String plistFilePath) { + if (_parsedValues.isEmpty) { + return {}; + } + return _parsedValues.removeAt(0); + } }