From 8bf6833f834a711c4cb500e1cf2bf3c122d2505f Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Mon, 6 Jul 2026 11:13:13 -0300 Subject: [PATCH 1/4] [PM-35111] feat: Add FedRAMP (bitwarden-gov.com) entry to environment selector Adds a `.gov` RegionType case backed by bitwarden-gov.com default URLs so users can select the new government cloud environment from the region selector, ahead of the broader FedRAMP support work tracked in PM-35110. --- .../Models/Domain/EnvironmentURLData.swift | 23 +++++- .../Domain/EnvironmentURLDataTests.swift | 29 +++++++ .../Models/Domain/EnvironmentURLs.swift | 1 + .../EnvironmentURLsTests+GovTests.swift | 75 +++++++++++++++++++ .../Platform/Models/Enum/RegionType.swift | 3 + .../en.lproj/Localizable.strings | 1 + .../Platform/Models/Enum/RegionType.swift | 5 ++ .../Models/Enum/RegionTypeTests.swift | 4 + .../Services/EnvironmentService.swift | 2 + .../EnvironmentServiceTests+GovTests.swift | 40 ++++++++++ .../UI/Auth/Utilities/RegionHelper.swift | 2 + .../UI/Auth/Utilities/RegionHelperTests.swift | 38 ++++++++-- 12 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLsTests+GovTests.swift create mode 100644 BitwardenShared/Core/Platform/Services/EnvironmentServiceTests+GovTests.swift diff --git a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift index 0c1ed4589c..7e554bb886 100644 --- a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift @@ -124,6 +124,8 @@ public extension EnvironmentURLData { .unitedStates case EnvironmentURLData.defaultEU.base: .europe + case EnvironmentURLData.defaultGov.base: + .gov default: .selfHosted } @@ -131,10 +133,14 @@ public extension EnvironmentURLData { /// The base url for send sharing. var sendShareURL: URL? { - guard region != .unitedStates else { - return URL(string: "https://send.bitwarden.com/#")! + switch region { + case .unitedStates: + URL(string: "https://send.bitwarden.com/#")! + case .gov: + URL(string: "https://send.bitwarden-gov.com/#")! + case .europe, .selfHosted: + subpageURL(additionalPath: "send") } - return subpageURL(additionalPath: "send") } /// The base url for the settings screen. @@ -203,4 +209,15 @@ public extension EnvironmentURLData { notifications: URL(string: "https://notifications.bitwarden.eu")!, webVault: URL(string: "https://vault.bitwarden.eu")!, ) + + /// The default URLs for the US government cloud (FedRAMP) region. + static let defaultGov = EnvironmentURLData( + api: URL(string: "https://api.bitwarden-gov.com")!, + base: URL(string: "https://vault.bitwarden-gov.com")!, + events: URL(string: "https://events.bitwarden-gov.com")!, + icons: URL(string: "https://icons.bitwarden-gov.com")!, + identity: URL(string: "https://identity.bitwarden-gov.com")!, + notifications: URL(string: "https://notifications.bitwarden-gov.com")!, + webVault: URL(string: "https://vault.bitwarden-gov.com")!, + ) } diff --git a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLDataTests.swift b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLDataTests.swift index acb916655f..e433e6a5c5 100644 --- a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLDataTests.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLDataTests.swift @@ -60,6 +60,23 @@ class EnvironmentURLDataTests: XCTestCase { ) } + /// `defaultGov` returns the properly configured `EnvironmentURLData` + /// with the default Urls for the government cloud (FedRAMP) region. + func test_defaultGov() { + XCTAssertEqual( + EnvironmentURLData.defaultGov, + EnvironmentURLData( + api: URL(string: "https://api.bitwarden-gov.com")!, + base: URL(string: "https://vault.bitwarden-gov.com")!, + events: URL(string: "https://events.bitwarden-gov.com")!, + icons: URL(string: "https://icons.bitwarden-gov.com")!, + identity: URL(string: "https://identity.bitwarden-gov.com")!, + notifications: URL(string: "https://notifications.bitwarden-gov.com")!, + webVault: URL(string: "https://vault.bitwarden-gov.com")!, + ), + ) + } + /// `importItemsURL` returns the import items URL for the base URL. func test_importItemsURL_baseURL() { let subject = EnvironmentURLData(base: URL(string: "https://vault.example.com")) @@ -130,6 +147,12 @@ class EnvironmentURLDataTests: XCTestCase { XCTAssertTrue(subject.region == .europe) } + /// `region` returns `.gov` if base URL is the same as the default for the government cloud (FedRAMP) region. + func test_region_gov() { + let subject = EnvironmentURLData(base: URL(string: "https://vault.bitwarden-gov.com")!) + XCTAssertTrue(subject.region == .gov) + } + /// `region` returns `.selfHosted` if base URL is neither the default for US nor for EU. func test_region_selfHost() { let subject = EnvironmentURLData(base: URL(string: "https://example.com")!) @@ -148,6 +171,12 @@ class EnvironmentURLDataTests: XCTestCase { XCTAssertEqual(subject.sendShareURL?.absoluteString, "https://vault.bitwarden.eu/#/send") } + /// `sendShareURL` returns the send URL for the government cloud (FedRAMP) region. + func test_sendShareURL_gov() { + let subject = EnvironmentURLData.defaultGov + XCTAssertEqual(subject.sendShareURL?.absoluteString, "https://send.bitwarden-gov.com/#") + } + /// `sendShareURL` returns the send URL for the base URL. func test_sendShareURL_baseURL() { let subject = EnvironmentURLData(base: URL(string: "https://vault.example.com")) diff --git a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLs.swift b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLs.swift index 78b96441a5..2f25dac473 100644 --- a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLs.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLs.swift @@ -123,6 +123,7 @@ public extension EnvironmentURLs { let environmentURLData: EnvironmentURLData = switch environmentURLData.region { case .europe: .defaultEU case .unitedStates: .defaultUS + case .gov: .defaultGov case .selfHosted: environmentURLData } diff --git a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLsTests+GovTests.swift b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLsTests+GovTests.swift new file mode 100644 index 0000000000..cea4428ad9 --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLsTests+GovTests.swift @@ -0,0 +1,75 @@ +import XCTest + +@testable import BitwardenKit + +extension EnvironmentURLsTests { + // MARK: Tests + + /// `init(environmentURLData:)` sets the URLs from the passed data when such data is the default + /// government cloud (FedRAMP) region. + func test_init_environmentURLData_defaultGov() { + let subject = EnvironmentURLs( + environmentURLData: EnvironmentURLData.defaultGov, + ) + XCTAssertEqual( + subject, + EnvironmentURLs( + apiURL: URL(string: "https://api.bitwarden-gov.com")!, + baseURL: URL(string: "https://vault.bitwarden-gov.com")!, + changeEmailURL: URL(string: "https://vault.bitwarden-gov.com/#/settings/account")!, + eventsURL: URL(string: "https://events.bitwarden-gov.com")!, + fillAssistRulesURL: URL(string: "https://github.com/bitwarden/map-the-web/releases/latest/download")!, + iconsURL: URL(string: "https://icons.bitwarden-gov.com")!, + identityURL: URL(string: "https://identity.bitwarden-gov.com")!, + importItemsURL: URL(string: "https://vault.bitwarden-gov.com/#/tools/import")!, + manageSubscriptionURL: URL(string: "https://vault.bitwarden-gov.com/#/settings/subscription")!, + proxyCookieRedirectConnectorURL: URL( + string: "https://vault.bitwarden-gov.com/proxy-cookie-redirect-connector.html", + )!, + recoveryCodeURL: URL(string: "https://vault.bitwarden-gov.com/#/recover-2fa")!, + sendShareURL: URL(string: "https://send.bitwarden-gov.com/#")!, + settingsURL: URL(string: "https://vault.bitwarden-gov.com/#/settings")!, + setUpTwoFactorURL: URL(string: "https://vault.bitwarden-gov.com/#/settings/security/two-factor")!, + upgradeToPremiumURL: URL( + // swiftlint:disable:next line_length + string: "https://vault.bitwarden-gov.com/#/settings/subscription/premium?callToAction=upgradeToPremium", + )!, + webVaultURL: URL(string: "https://vault.bitwarden-gov.com")!, + ), + ) + } + + /// `init(environmentURLData:)` defaults to the pre-defined government cloud (FedRAMP) URLs if the + /// base URL matches the gov environment. + func test_init_environmentURLData_baseURL_gov() { + let subject = EnvironmentURLs( + environmentURLData: EnvironmentURLData(base: URL(string: "https://vault.bitwarden-gov.com")!), + ) + XCTAssertEqual( + subject, + EnvironmentURLs( + apiURL: URL(string: "https://api.bitwarden-gov.com")!, + baseURL: URL(string: "https://vault.bitwarden-gov.com")!, + changeEmailURL: URL(string: "https://vault.bitwarden-gov.com/#/settings/account")!, + eventsURL: URL(string: "https://events.bitwarden-gov.com")!, + fillAssistRulesURL: URL(string: "https://github.com/bitwarden/map-the-web/releases/latest/download")!, + iconsURL: URL(string: "https://icons.bitwarden-gov.com")!, + identityURL: URL(string: "https://identity.bitwarden-gov.com")!, + importItemsURL: URL(string: "https://vault.bitwarden-gov.com/#/tools/import")!, + manageSubscriptionURL: URL(string: "https://vault.bitwarden-gov.com/#/settings/subscription")!, + proxyCookieRedirectConnectorURL: URL( + string: "https://vault.bitwarden-gov.com/proxy-cookie-redirect-connector.html", + )!, + recoveryCodeURL: URL(string: "https://vault.bitwarden-gov.com/#/recover-2fa")!, + sendShareURL: URL(string: "https://send.bitwarden-gov.com/#")!, + settingsURL: URL(string: "https://vault.bitwarden-gov.com/#/settings")!, + setUpTwoFactorURL: URL(string: "https://vault.bitwarden-gov.com/#/settings/security/two-factor")!, + upgradeToPremiumURL: URL( + // swiftlint:disable:next line_length + string: "https://vault.bitwarden-gov.com/#/settings/subscription/premium?callToAction=upgradeToPremium", + )!, + webVaultURL: URL(string: "https://vault.bitwarden-gov.com")!, + ), + ) + } +} diff --git a/BitwardenKit/Core/Platform/Models/Enum/RegionType.swift b/BitwardenKit/Core/Platform/Models/Enum/RegionType.swift index 3088379d3c..e26e862ee6 100644 --- a/BitwardenKit/Core/Platform/Models/Enum/RegionType.swift +++ b/BitwardenKit/Core/Platform/Models/Enum/RegionType.swift @@ -8,6 +8,9 @@ public enum RegionType: CaseIterable, Sendable { /// The European region. case europe + /// The government cloud (FedRAMP) region. + case gov + /// A self-hosted instance. case selfHosted } diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index 05a606d62d..1a19df5796 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -563,6 +563,7 @@ "ThereAreNoBankAccountsInYourVault" = "There are no bank accounts in your vault"; "US" = "US"; "EU" = "EU"; +"Gov" = "Gov"; "SelfHosted" = "Self-hosted"; "UpdateWeakMasterPasswordWarning" = "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."; "CurrentMasterPasswordRequired" = "Current master password (required)"; diff --git a/BitwardenShared/Core/Platform/Models/Enum/RegionType.swift b/BitwardenShared/Core/Platform/Models/Enum/RegionType.swift index 9803a0a781..dbfa8dc53e 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/RegionType.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/RegionType.swift @@ -9,6 +9,7 @@ extension RegionType { var localizedName: String { switch self { case .europe: Localizations.eu + case .gov: Localizations.gov case .selfHosted: Localizations.selfHosted case .unitedStates: Localizations.us } @@ -18,6 +19,7 @@ extension RegionType { var baseURLDescription: String { switch self { case .europe: "bitwarden.eu" + case .gov: "bitwarden-gov.com" case .selfHosted: Localizations.selfHosted case .unitedStates: "bitwarden.com" } @@ -28,6 +30,8 @@ extension RegionType { switch self { case .europe: .defaultEU + case .gov: + .defaultGov case .unitedStates: .defaultUS case .selfHosted: @@ -39,6 +43,7 @@ extension RegionType { var errorReporterName: String { switch self { case .europe: "EU" + case .gov: "Gov" case .selfHosted: "Self-Hosted" case .unitedStates: "US" } diff --git a/BitwardenShared/Core/Platform/Models/Enum/RegionTypeTests.swift b/BitwardenShared/Core/Platform/Models/Enum/RegionTypeTests.swift index 057e1dbd47..5fb804a3c6 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/RegionTypeTests.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/RegionTypeTests.swift @@ -10,6 +10,7 @@ class RegionTypeTests: BitwardenTestCase { /// `getter:localizedName` returns the correct values. func test_localizedName() { XCTAssertEqual(RegionType.europe.localizedName, Localizations.eu) + XCTAssertEqual(RegionType.gov.localizedName, Localizations.gov) XCTAssertEqual(RegionType.selfHosted.localizedName, Localizations.selfHosted) XCTAssertEqual(RegionType.unitedStates.localizedName, Localizations.us) } @@ -17,6 +18,7 @@ class RegionTypeTests: BitwardenTestCase { /// `getter:baseURLDescription` returns the correct values. func test_baseURLDescription() { XCTAssertEqual(RegionType.europe.baseURLDescription, "bitwarden.eu") + XCTAssertEqual(RegionType.gov.baseURLDescription, "bitwarden-gov.com") XCTAssertEqual(RegionType.selfHosted.baseURLDescription, Localizations.selfHosted) XCTAssertEqual(RegionType.unitedStates.baseURLDescription, "bitwarden.com") } @@ -24,6 +26,7 @@ class RegionTypeTests: BitwardenTestCase { /// `getter:defaultURLs` returns the correct values. func test_defaultURLs() { XCTAssertEqual(RegionType.europe.defaultURLs?.api?.absoluteString, "https://api.bitwarden.eu") + XCTAssertEqual(RegionType.gov.defaultURLs?.api?.absoluteString, "https://api.bitwarden-gov.com") XCTAssertNil(RegionType.selfHosted.defaultURLs) XCTAssertEqual(RegionType.unitedStates.defaultURLs?.api?.absoluteString, "https://api.bitwarden.com") } @@ -31,6 +34,7 @@ class RegionTypeTests: BitwardenTestCase { /// `getter:errorReporterName` returns the correct values. func test_errorReporterName() { XCTAssertEqual(RegionType.europe.errorReporterName, "EU") + XCTAssertEqual(RegionType.gov.errorReporterName, "Gov") XCTAssertEqual(RegionType.selfHosted.errorReporterName, "Self-Hosted") XCTAssertEqual(RegionType.unitedStates.errorReporterName, "US") } diff --git a/BitwardenShared/Core/Platform/Services/EnvironmentService.swift b/BitwardenShared/Core/Platform/Services/EnvironmentService.swift index d70f8cae16..8466814405 100644 --- a/BitwardenShared/Core/Platform/Services/EnvironmentService.swift +++ b/BitwardenShared/Core/Platform/Services/EnvironmentService.swift @@ -168,6 +168,8 @@ extension DefaultEnvironmentService { .unitedStates } else if environmentURLs.baseURL == EnvironmentURLData.defaultEU.base { .europe + } else if environmentURLs.baseURL == EnvironmentURLData.defaultGov.base { + .gov } else { .selfHosted } diff --git a/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests+GovTests.swift b/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests+GovTests.swift new file mode 100644 index 0000000000..5f0e15720b --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests+GovTests.swift @@ -0,0 +1,40 @@ +import BitwardenKit +import XCTest + +@testable import BitwardenShared +@testable import BitwardenSharedMocks + +extension EnvironmentServiceTests { + // MARK: Tests + + /// `loadURLsForActiveAccount()` handles government cloud (FedRAMP) URLs + func test_loadURLsForActiveAccount_gov() async { + let urls = EnvironmentURLData.defaultGov + let account = Account.fixture(settings: .fixture(environmentURLs: urls)) + stateService.activeAccount = account + stateService.environmentURLs = [account.profile.userId: urls] + + await subject.loadURLsForActiveAccount() + + XCTAssertEqual(subject.apiURL, URL(string: "https://api.bitwarden-gov.com")) + XCTAssertEqual(subject.baseURL, URL(string: "https://vault.bitwarden-gov.com")) + XCTAssertEqual(subject.changeEmailURL, URL(string: "https://vault.bitwarden-gov.com/#/settings/account")) + XCTAssertEqual(subject.eventsURL, URL(string: "https://events.bitwarden-gov.com")) + XCTAssertEqual(subject.iconsURL, URL(string: "https://icons.bitwarden-gov.com")) + XCTAssertEqual(subject.identityURL, URL(string: "https://identity.bitwarden-gov.com")) + XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden-gov.com/#/tools/import")) + // swiftlint:disable:next line_length + XCTAssertEqual(subject.proxyCookieRedirectConnectorURL, URL(string: "https://vault.bitwarden-gov.com/proxy-cookie-redirect-connector.html")) + XCTAssertEqual(subject.recoveryCodeURL, URL(string: "https://vault.bitwarden-gov.com/#/recover-2fa")) + XCTAssertEqual(subject.region, .gov) + XCTAssertEqual(subject.sendShareURL, URL(string: "https://send.bitwarden-gov.com/#")) + XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden-gov.com/#/settings")) + // swiftlint:disable:next line_length + XCTAssertEqual(subject.setUpTwoFactorURL, URL(string: "https://vault.bitwarden-gov.com/#/settings/security/two-factor")) + XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden-gov.com")) + XCTAssertEqual(stateService.preAuthEnvironmentURLs, urls) + + XCTAssertEqual(errorReporter.region?.region, "Gov") + XCTAssertEqual(errorReporter.region?.isPreAuth, false) + } +} diff --git a/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift b/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift index e01153b2e4..eea332226b 100644 --- a/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift +++ b/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift @@ -70,6 +70,8 @@ class RegionHelper { await delegate?.setRegion(.unitedStates, urls) } else if urls.base == EnvironmentURLData.defaultEU.base { await delegate?.setRegion(.europe, urls) + } else if urls.base == EnvironmentURLData.defaultGov.base { + await delegate?.setRegion(.gov, urls) } else { await delegate?.setRegion(.selfHosted, urls) } diff --git a/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift b/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift index 517af39b9e..c251728804 100644 --- a/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift +++ b/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift @@ -47,7 +47,7 @@ class RegionHelperTests: BitwardenTestCase { let alert = try XCTUnwrap(coordinator.alertShown.last) XCTAssertEqual(alert.title, Localizations.creatingOn) XCTAssertNil(alert.message) - XCTAssertEqual(alert.alertActions.count, 4) + XCTAssertEqual(alert.alertActions.count, 5) XCTAssertEqual(alert.alertActions[0].title, "bitwarden.com") try await alert.tapAction(title: "bitwarden.com") @@ -64,7 +64,7 @@ class RegionHelperTests: BitwardenTestCase { let alert = try XCTUnwrap(coordinator.alertShown.last) XCTAssertEqual(alert.title, Localizations.creatingOn) XCTAssertNil(alert.message) - XCTAssertEqual(alert.alertActions.count, 4) + XCTAssertEqual(alert.alertActions.count, 5) XCTAssertEqual(alert.alertActions[1].title, "bitwarden.eu") try await alert.tapAction(title: "bitwarden.eu") @@ -73,6 +73,23 @@ class RegionHelperTests: BitwardenTestCase { XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.europe.defaultURLs) } + /// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap bitwarden-gov.com. + @MainActor + func test_presentRegionSelectorAlert_gov() async throws { + await subject.presentRegionSelectorAlert(title: Localizations.creatingOn, currentRegion: .unitedStates) + + let alert = try XCTUnwrap(coordinator.alertShown.last) + XCTAssertEqual(alert.title, Localizations.creatingOn) + XCTAssertNil(alert.message) + XCTAssertEqual(alert.alertActions.count, 5) + + XCTAssertEqual(alert.alertActions[2].title, "bitwarden-gov.com") + try await alert.tapAction(title: "bitwarden-gov.com") + XCTAssertTrue(regionDelegate.setRegionCalled) + XCTAssertEqual(regionDelegate.setRegionType, .gov) + XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.gov.defaultURLs) + } + /// `presentRegionSelectorAlert(title:currentRegion)` shows alert and tap selfhosted. @MainActor func test_presentRegionSelectorAlert_selfHosted() async throws { @@ -81,9 +98,9 @@ class RegionHelperTests: BitwardenTestCase { let alert = try XCTUnwrap(coordinator.alertShown.last) XCTAssertEqual(alert.title, Localizations.creatingOn) XCTAssertNil(alert.message) - XCTAssertEqual(alert.alertActions.count, 4) + XCTAssertEqual(alert.alertActions.count, 5) - XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted) + XCTAssertEqual(alert.alertActions[3].title, Localizations.selfHosted) try await alert.tapAction(title: Localizations.selfHosted) XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe)) } @@ -96,9 +113,9 @@ class RegionHelperTests: BitwardenTestCase { let alert = try XCTUnwrap(coordinator.alertShown.last) XCTAssertEqual(alert.title, Localizations.loggingInOn) XCTAssertNil(alert.message) - XCTAssertEqual(alert.alertActions.count, 4) + XCTAssertEqual(alert.alertActions.count, 5) - XCTAssertEqual(alert.alertActions[2].title, Localizations.selfHosted) + XCTAssertEqual(alert.alertActions[3].title, Localizations.selfHosted) try await alert.tapAction(title: Localizations.selfHosted) XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .unitedStates)) } @@ -130,6 +147,15 @@ class RegionHelperTests: BitwardenTestCase { XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.europe.defaultURLs) } + /// `loadRegion()` with pre auth region + func test_loadRegion_gov() async throws { + stateService.preAuthEnvironmentURLs = .defaultGov + await subject.loadRegion() + XCTAssertTrue(regionDelegate.setRegionCalled) + XCTAssertEqual(regionDelegate.setRegionType, .gov) + XCTAssertEqual(regionDelegate.setRegionUrls, RegionType.gov.defaultURLs) + } + /// `loadRegion()` with pre auth region func test_loadRegion_selfHosted() async throws { stateService.preAuthEnvironmentURLs = EnvironmentURLData(base: URL(string: "https://selfhosted.com")) From 0f49a49bee59db62670ebba48888d438b72670bf Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Tue, 7 Jul 2026 15:52:35 -0300 Subject: [PATCH 2/4] PM-35111 fix internal url --- .../Core/Platform/Models/Domain/EnvironmentURLData.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift index a1e016d25a..f9f1372ac3 100644 --- a/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/EnvironmentURLData.swift @@ -129,7 +129,7 @@ public extension EnvironmentURLData { URL(string: "https://send.bitwarden.com/#")! case .gov: URL(string: "https://send.bitwarden-gov.com/#")! - case .europe, .selfHosted: + case .europe, .internal, .selfHosted: subpageURL(additionalPath: "send") } } From 70145ce1e1c1a1929879b13a655f5d09d8feacda Mon Sep 17 00:00:00 2001 From: Federico Maccaroni Date: Tue, 7 Jul 2026 18:03:19 -0300 Subject: [PATCH 3/4] [PM-37864] feat: Hide create account button based on server disableUserRegistration config Adds `settings.disableUserRegistration` to `ConfigResponseModel` and `ServerConfig`. The landing processor subscribes to `configPublisher` in `init` and reactively hides the "New around here? Create account" row whenever the server signals that user registration is disabled. --- .../Platform/Models/Domain/ServerConfig.swift | 28 ++++++++++ .../API/Fixtures/APITestData+Config.swift | 5 ++ .../ValidServerConfigDisableRegistration.json | 18 +++++++ .../API/Response/ConfigResponseModel.swift | 23 ++++++++ .../UI/Auth/Landing/LandingProcessor.swift | 8 +++ .../Auth/Landing/LandingProcessorTests.swift | 54 +++++++++++++++++++ .../UI/Auth/Landing/LandingState.swift | 6 +++ .../UI/Auth/Landing/LandingStateTests.swift | 5 ++ .../Landing/LandingView+SnapshotTests.swift | 7 +++ .../UI/Auth/Landing/LandingView.swift | 22 ++++---- 10 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 BitwardenKit/Core/Platform/Services/API/Fixtures/ValidServerConfigDisableRegistration.json diff --git a/BitwardenKit/Core/Platform/Models/Domain/ServerConfig.swift b/BitwardenKit/Core/Platform/Models/Domain/ServerConfig.swift index 8306b46dcd..8625c9aaf7 100644 --- a/BitwardenKit/Core/Platform/Models/Domain/ServerConfig.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/ServerConfig.swift @@ -26,6 +26,9 @@ public struct ServerConfig: Equatable, Codable, Sendable { /// Third party server information. public let server: ThirdPartyServerConfig? + /// Server-level settings. + public let settings: ServerSettings? + /// The version of the server. public let version: String @@ -36,6 +39,7 @@ public struct ServerConfig: Equatable, Codable, Sendable { featureStates = responseModel.featureStates ?? [:] gitHash = responseModel.gitHash server = responseModel.server.map(ThirdPartyServerConfig.init) + settings = responseModel.settings.map(ServerSettings.init) version = responseModel.version } @@ -177,3 +181,27 @@ public struct ServerCommunicationBootstrapSettings: Equatable, Codable, Sendable cookieDomain = responseModel.cookieDomain } } + +// MARK: - ServerSettings + +/// Domain model for server-level settings. +public struct ServerSettings: Equatable, Codable, Sendable { + // MARK: Properties + + /// Whether user registration is disabled on this server. + public let disableUserRegistration: Bool + + // MARK: Initialization + + /// Creates a new `ServerSettings`. + /// + /// - Parameter disableUserRegistration: Whether user registration is disabled on this server. + /// + public init(disableUserRegistration: Bool) { + self.disableUserRegistration = disableUserRegistration + } + + public init(responseModel: ServerSettingsResponseModel) { + disableUserRegistration = responseModel.disableUserRegistration + } +} diff --git a/BitwardenKit/Core/Platform/Services/API/Fixtures/APITestData+Config.swift b/BitwardenKit/Core/Platform/Services/API/Fixtures/APITestData+Config.swift index 3631c5403e..7bc0e3cd79 100644 --- a/BitwardenKit/Core/Platform/Services/API/Fixtures/APITestData+Config.swift +++ b/BitwardenKit/Core/Platform/Services/API/Fixtures/APITestData+Config.swift @@ -4,4 +4,9 @@ import TestHelpers public extension APITestData { /// A valid server configuration to produce a `ConfigResponseModel`. static let validServerConfig = loadFromJsonBundle(resource: "ValidServerConfig") + + /// A valid server configuration with `disableUserRegistration` set to `true`. + static let validServerConfigDisableRegistration = loadFromJsonBundle( + resource: "ValidServerConfigDisableRegistration", + ) } diff --git a/BitwardenKit/Core/Platform/Services/API/Fixtures/ValidServerConfigDisableRegistration.json b/BitwardenKit/Core/Platform/Services/API/Fixtures/ValidServerConfigDisableRegistration.json new file mode 100644 index 0000000000..16828ee1d3 --- /dev/null +++ b/BitwardenKit/Core/Platform/Services/API/Fixtures/ValidServerConfigDisableRegistration.json @@ -0,0 +1,18 @@ +{ + "version": "2024.4.2", + "gitHash": "75238191", + "server": null, + "environment": { + "cloudRegion": "US", + "vault": "https://vault.qa.bitwarden.pw", + "api": "https://api.qa.bitwarden.pw", + "identity": "https://identity.qa.bitwarden.pw", + "notifications": "https://notifications.qa.bitwarden.pw", + "sso": "https://sso.qa.bitwarden.pw" + }, + "settings": { + "disableUserRegistration": true + }, + "featureStates": {}, + "object": "config" +} diff --git a/BitwardenKit/Core/Platform/Services/API/Response/ConfigResponseModel.swift b/BitwardenKit/Core/Platform/Services/API/Response/ConfigResponseModel.swift index 7363d3223c..a0c8374eaa 100644 --- a/BitwardenKit/Core/Platform/Services/API/Response/ConfigResponseModel.swift +++ b/BitwardenKit/Core/Platform/Services/API/Response/ConfigResponseModel.swift @@ -23,6 +23,9 @@ public struct ConfigResponseModel: Equatable, JSONResponse { /// Third party server information. public let server: ThirdPartyConfigResponseModel? + /// Server-level settings. + public let settings: ServerSettingsResponseModel? + /// The version of the server. public let version: String @@ -36,6 +39,7 @@ public struct ConfigResponseModel: Equatable, JSONResponse { /// - featureStates: Feature flags to configure the client. /// - gitHash: The git hash of the server. /// - server: Third party server information. + /// - settings: Server-level settings. /// - version: The version of the server. public init( communication: CommunicationSettingsResponseModel?, @@ -43,6 +47,7 @@ public struct ConfigResponseModel: Equatable, JSONResponse { featureStates: [String: AnyCodable]?, gitHash: String?, server: ThirdPartyConfigResponseModel?, + settings: ServerSettingsResponseModel? = nil, version: String, ) { self.communication = communication @@ -50,6 +55,7 @@ public struct ConfigResponseModel: Equatable, JSONResponse { self.featureStates = featureStates self.gitHash = gitHash self.server = server + self.settings = settings self.version = version } } @@ -144,6 +150,23 @@ public struct ThirdPartyConfigResponseModel: Equatable, JSONResponse { } } +// MARK: - ServerSettingsResponseModel + +/// API response model for server-level settings in a configuration response. +public struct ServerSettingsResponseModel: Equatable, JSONResponse { + /// Whether user registration is disabled on this server. + public let disableUserRegistration: Bool + + // MARK: Initializers + + /// Initializes a `ServerSettingsResponseModel`. + /// + /// - Parameter disableUserRegistration: Whether user registration is disabled on this server. + public init(disableUserRegistration: Bool) { + self.disableUserRegistration = disableUserRegistration + } +} + // MARK: - EnvironmentServerConfigResponseModel /// API response model for the environment URLs in a configuration response. diff --git a/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift b/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift index 4f4c749837..3d50a2e19d 100644 --- a/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift +++ b/BitwardenShared/UI/Auth/Landing/LandingProcessor.swift @@ -58,6 +58,14 @@ class LandingProcessor: StateProcessor Date: Wed, 8 Jul 2026 18:23:45 -0300 Subject: [PATCH 4/4] [PM-39884] feat: Hide Gov region from registration picker and guard against disableUserRegistration FedRAMP does not allow self-registration, so the Gov region is now excluded from the region selector on the start registration screen. Additionally, the processor now subscribes to configPublisher and shows an alert + dismisses when disableUserRegistration becomes true after a region change, mirroring the pattern used in LandingProcessor. --- .../en.lproj/Localizable.strings | 1 + .../UI/Auth/Extensions/Alert+Auth.swift | 15 ++++ .../UI/Auth/Extensions/AlertAuthTests.swift | 14 ++++ .../StartRegistrationProcessor.swift | 27 +++++++ .../StartRegistrationProcessorTests.swift | 79 +++++++++++++++++-- .../UI/Auth/Utilities/RegionHelper.swift | 33 +++++--- .../UI/Auth/Utilities/RegionHelperTests.swift | 21 ++++- 7 files changed, 172 insertions(+), 18 deletions(-) diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index cca8368b16..adb38d70a2 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -39,6 +39,7 @@ "RemoveAccount" = "Remove account"; "RemoveAccountConfirmation" = "Are you sure you want to remove this account?"; "AccountAlreadyAdded" = "Account already added"; +"AccountCreationNotAllowed" = "Account creation is not allowed for this server."; "SwitchToAlreadyAddedAccountConfirmation" = "Would you like to switch to it now?"; "MasterPassword" = "Master password"; "MoreOptions" = "More options"; diff --git a/BitwardenShared/UI/Auth/Extensions/Alert+Auth.swift b/BitwardenShared/UI/Auth/Extensions/Alert+Auth.swift index c1bbaba437..062f80ab25 100644 --- a/BitwardenShared/UI/Auth/Extensions/Alert+Auth.swift +++ b/BitwardenShared/UI/Auth/Extensions/Alert+Auth.swift @@ -79,6 +79,21 @@ extension Alert { ) } + /// An alert notifying the user that account creation is not allowed for the current server. + /// + /// - Parameter action: The action to perform when the user dismisses the alert. + /// - Returns: An alert notifying the user that account creation is not allowed. + /// + static func registrationDisabled(action: @escaping () async -> Void) -> Alert { + Alert( + title: Localizations.anErrorHasOccurred, + message: Localizations.accountCreationNotAllowed, + alertActions: [ + AlertAction(title: Localizations.ok, style: .default) { _ in await action() }, + ], + ) + } + /// Display the options to log out of, lock, or remove the selected profile switcher item. /// /// - Parameters: diff --git a/BitwardenShared/UI/Auth/Extensions/AlertAuthTests.swift b/BitwardenShared/UI/Auth/Extensions/AlertAuthTests.swift index 77e6f1de7e..f993966ab6 100644 --- a/BitwardenShared/UI/Auth/Extensions/AlertAuthTests.swift +++ b/BitwardenShared/UI/Auth/Extensions/AlertAuthTests.swift @@ -382,4 +382,18 @@ class AlertAuthTests: BitwardenTestCase { // swiftlint:disable:this type_body_le try await subject.tapAction(title: Localizations.yes) XCTAssertTrue(actionCalled) } + + /// `registrationDisabled(action:)` builds the correct alert and fires the action on OK. + func test_registrationDisabled() async throws { + var actionCalled = false + let subject = Alert.registrationDisabled { actionCalled = true } + + XCTAssertEqual(subject.title, Localizations.anErrorHasOccurred) + XCTAssertEqual(subject.message, Localizations.accountCreationNotAllowed) + XCTAssertEqual(subject.alertActions.count, 1) + XCTAssertEqual(subject.alertActions[0].title, Localizations.ok) + + try await subject.tapAction(title: Localizations.ok) + XCTAssertTrue(actionCalled) + } } diff --git a/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessor.swift b/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessor.swift index ac7e6ccdec..4b0bc05492 100644 --- a/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessor.swift +++ b/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessor.swift @@ -93,6 +93,18 @@ class StartRegistrationProcessor: StateProcessor< self.delegate = delegate self.services = services super.init(state: state) + + Task { + for await metaConfig in await services.configService.configPublisher() { + guard metaConfig?.isPreAuth == true, + metaConfig?.serverConfig?.settings?.disableUserRegistration == true, + viewIsVisible + else { continue } + await coordinator.showAlert(.registrationDisabled { + coordinator.navigate(to: .dismiss) + }) + } + } } // MARK: Methods @@ -107,6 +119,7 @@ class StartRegistrationProcessor: StateProcessor< await regionHelper.presentRegionSelectorAlert( title: Localizations.creatingOn, currentRegion: state.region, + excludingRegions: [.gov], ) case .startRegistration: await startRegistration() @@ -182,6 +195,15 @@ class StartRegistrationProcessor: StateProcessor< } } + /// Refreshes the server configuration for the current pre-auth environment. + /// + private func refreshConfig() async { + await services.configService.getConfig( + forceRefresh: true, + isPreAuth: true, + ) + } + /// Shows a `StartRegistrationError` alert. /// /// - Parameter error: The error that occurred. @@ -227,5 +249,10 @@ extension StartRegistrationProcessor: RegionDelegate { state.region = region state.showReceiveMarketingToggle = state.region != .selfHosted await delegate?.didChangeRegion() + // Using Task avoids delaying region-change side effects (e.g. closing self-host sheet) + // when internet speed is low — mirrors LandingProcessor.setRegion. + Task { + await refreshConfig() + } } } diff --git a/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessorTests.swift b/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessorTests.swift index 9707c4ccd3..6af5179a33 100644 --- a/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessorTests.swift +++ b/BitwardenShared/UI/Auth/StartRegistration/StartRegistrationProcessorTests.swift @@ -16,12 +16,13 @@ class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable: var authRepository: MockAuthRepository! var client: MockHTTPClient! + var configService: MockConfigService! var coordinator: MockCoordinator! var delegate: MockStartRegistrationDelegate! + var environmentService: MockEnvironmentService! var errorReporter: MockErrorReporter! - var subject: StartRegistrationProcessor! var stateService: MockStateService! - var environmentService: MockEnvironmentService! + var subject: StartRegistrationProcessor! // MARK: Setup & Teardown @@ -29,6 +30,7 @@ class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable: super.setUp() authRepository = MockAuthRepository() client = MockHTTPClient() + configService = MockConfigService() coordinator = MockCoordinator() delegate = MockStartRegistrationDelegate() environmentService = MockEnvironmentService() @@ -40,6 +42,7 @@ class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable: delegate: delegate, services: ServiceContainer.withMocks( authRepository: authRepository, + configService: configService, environmentService: environmentService, errorReporter: errorReporter, httpClient: client, @@ -53,16 +56,17 @@ class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable: super.tearDown() authRepository = nil client = nil + configService = nil coordinator = nil environmentService = nil errorReporter = nil - subject = nil stateService = nil + subject = nil } // MARK: Tests - /// `perform(_:)` with `.regionTapped` navigates to the region selection screen. + /// `perform(_:)` with `.regionTapped` shows US, EU, Self-Hosted — Gov is excluded. @MainActor func test_perform_regionTapped() async throws { await subject.perform(.regionTapped) @@ -70,7 +74,8 @@ class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable: var alert = try XCTUnwrap(coordinator.alertShown.last) XCTAssertEqual(alert.title, Localizations.creatingOn) XCTAssertNil(alert.message) - XCTAssertEqual(alert.alertActions.count, 4) + XCTAssertEqual(alert.alertActions.count, 4) // US + EU + Self-Hosted + Cancel (no Gov) + XCTAssertNil(alert.alertActions.first(where: { $0.title == "bitwarden-gov.com" })) XCTAssertEqual(alert.alertActions[0].title, "bitwarden.com") try await alert.tapAction(title: "bitwarden.com") @@ -89,6 +94,70 @@ class StartRegistrationProcessorTests: BitwardenTestCase { // swiftlint:disable: XCTAssertEqual(coordinator.routes.last, .selfHosted(currentRegion: .europe)) } + /// When `disableUserRegistration` becomes true while the view is visible, an alert is shown + /// and tapping OK navigates back. + @MainActor + func test_configPublisher_disableUserRegistration_true_whileVisible() async throws { + await subject.perform(.appeared) + + configService.configSubject.send(MetaServerConfig( + isPreAuth: true, + userId: nil, + serverConfig: ServerConfig( + date: Date(), + responseModel: ConfigResponseModel( + communication: nil, + environment: nil, + featureStates: [:], + gitHash: nil, + server: nil, + settings: ServerSettingsResponseModel(disableUserRegistration: true), + version: "2024.4.0", + ), + ), + )) + waitFor(coordinator.alertShown.last?.message == Localizations.accountCreationNotAllowed) + + let alert = try XCTUnwrap(coordinator.alertShown.last) + XCTAssertEqual(alert.title, Localizations.anErrorHasOccurred) + XCTAssertEqual(alert.alertActions.count, 1) + try await alert.tapAction(title: Localizations.ok) + XCTAssertEqual(coordinator.routes.last, .dismiss) + } + + /// When `disableUserRegistration` becomes true but the view is not visible, no alert is shown. + @MainActor + func test_configPublisher_disableUserRegistration_viewNotVisible_noAlert() async throws { + // viewIsVisible is false — `.appeared` has not been called + configService.configSubject.send(MetaServerConfig( + isPreAuth: true, + userId: nil, + serverConfig: ServerConfig( + date: Date(), + responseModel: ConfigResponseModel( + communication: nil, + environment: nil, + featureStates: [:], + gitHash: nil, + server: nil, + settings: ServerSettingsResponseModel(disableUserRegistration: true), + version: "2024.4.0", + ), + ), + )) + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertTrue(coordinator.alertShown.isEmpty) + } + + /// `setRegion` triggers a config refresh so the processor can react to `disableUserRegistration`. + @MainActor + func test_setRegion_callsRefreshConfig() async { + await subject.setRegion(.europe, .defaultEU) + waitFor(configService.configMocker.called) + XCTAssertEqual(configService.configMocker.invokedParam?.forceRefresh, true) + XCTAssertEqual(configService.configMocker.invokedParam?.isPreAuth, true) + } + /// `perform(_:)` with `.startRegistration` sets preAuthUrls for the given email and navigates to check email. @MainActor func test_perform_startRegistration_setPreAuthUrls_checkEmail() async throws { diff --git a/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift b/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift index 14c292c317..90416c7711 100644 --- a/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift +++ b/BitwardenShared/UI/Auth/Utilities/RegionHelper.swift @@ -35,19 +35,30 @@ class RegionHelper { /// Builds an alert for region selection and navigates to the alert. /// - func presentRegionSelectorAlert(title: String, currentRegion: RegionType?) async { - let actions = RegionType.userSelectableCases.map { region in - AlertAction(title: region.baseURLDescription, style: .default) { _ in - if let urls = region.defaultURLs { - await self.delegate?.setRegion(region, urls) - } else { - await self.coordinator.navigate( - to: .selfHosted(currentRegion: currentRegion ?? .unitedStates), - context: self.delegate, - ) + /// - Parameters: + /// - title: The title of the alert. + /// - currentRegion: The currently selected region. + /// - excludingRegions: Regions to omit from the picker. Defaults to empty (all selectable regions shown). + /// + func presentRegionSelectorAlert( + title: String, + currentRegion: RegionType?, + excludingRegions: [RegionType] = [], + ) async { + let actions = RegionType.userSelectableCases + .filter { !excludingRegions.contains($0) } + .map { region in + AlertAction(title: region.baseURLDescription, style: .default) { _ in + if let urls = region.defaultURLs { + await self.delegate?.setRegion(region, urls) + } else { + await self.coordinator.navigate( + to: .selfHosted(currentRegion: currentRegion ?? .unitedStates), + context: self.delegate, + ) + } } } - } let cancelAction = AlertAction(title: Localizations.cancel, style: .cancel) let alert = Alert( title: title, diff --git a/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift b/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift index a7567bce9e..96581fe835 100644 --- a/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift +++ b/BitwardenShared/UI/Auth/Utilities/RegionHelperTests.swift @@ -121,7 +121,7 @@ class RegionHelperTests: BitwardenTestCase { } /// `presentRegionSelectorAlert(title:currentRegion:)` offers exactly the user-selectable regions - /// (US, EU, self-hosted) plus cancel — the internal (`bitwarden.pw`) region is detected + /// (US, EU, Gov, self-hosted) plus cancel — the internal (`bitwarden.pw`) region is detected /// automatically and must never appear in the picker. @MainActor func test_presentRegionSelectorAlert_excludesInternal() async throws { @@ -129,7 +129,24 @@ class RegionHelperTests: BitwardenTestCase { let alert = try XCTUnwrap(coordinator.alertShown.last) let actionTitles = alert.alertActions.map(\.title) - XCTAssertEqual(actionTitles, ["bitwarden.com", "bitwarden.eu", Localizations.selfHosted, Localizations.cancel]) + XCTAssertEqual( + actionTitles, + ["bitwarden.com", "bitwarden.eu", "bitwarden-gov.com", Localizations.selfHosted, Localizations.cancel], + ) + } + + /// `presentRegionSelectorAlert(title:currentRegion:excludingRegions:)` omits excluded regions. + @MainActor + func test_presentRegionSelectorAlert_excludingRegions() async throws { + await subject.presentRegionSelectorAlert( + title: Localizations.creatingOn, + currentRegion: .unitedStates, + excludingRegions: [.gov], + ) + + let alert = try XCTUnwrap(coordinator.alertShown.last) + XCTAssertEqual(alert.alertActions.count, 4) // US + EU + Self-Hosted + Cancel + XCTAssertNil(alert.alertActions.first(where: { $0.title == "bitwarden-gov.com" })) } /// `loadRegion()` with pre auth region as nil default to us