From 5f54987bb9877c0dfbe0decb1a719c552840340d Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Fri, 17 Jul 2026 14:25:23 -0500 Subject: [PATCH 1/2] [PM-40460] feat: Enforce Send type restriction via Send Controls policy --- .../Models/Domain/SendPolicyOptions.swift | 35 +++++- .../Domain/SendPolicyOptionsTests.swift | 102 ++++++++++++++++++ .../Tools/Repositories/SendRepository.swift | 42 +++++--- .../Repositories/SendRepositoryTests.swift | 32 +++++- .../TestHelpers/MockSendRepository.swift | 8 +- .../Vault/Models/Enum/PolicyOptionType.swift | 5 + .../Vault/Services/PolicyServiceTests.swift | 56 ++++++++++ .../Send/SendList/SendListProcessor.swift | 8 +- .../SendList/SendListProcessorTests.swift | 28 +++++ .../Send/Send/SendList/SendListState.swift | 5 + .../SendListView+ViewInspectorTests.swift | 31 ++++++ .../Send/Send/SendList/SendListView.swift | 6 +- 12 files changed, 333 insertions(+), 25 deletions(-) diff --git a/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptions.swift b/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptions.swift index dfc1fb5f60..f2ae6d477b 100644 --- a/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptions.swift +++ b/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptions.swift @@ -14,6 +14,9 @@ struct SendPolicyOptions: Equatable, Sendable { /// The access type the user is required to use, or `nil` if the access type is unrestricted. var enforcedAccessType: SendAccessType? + /// The Send type the user is required to use, or `nil` if both types are allowed (unrestricted). + var enforcedSendType: SendType? + /// Whether the hide-email option is disabled. var isHideEmailDisabled = false @@ -24,10 +27,12 @@ struct SendPolicyOptions: Equatable, Sendable { extension SendPolicyOptions { /// Creates the Send policy options from the Send Controls policies that apply to the user. /// - /// When multiple policies apply, a boolean restriction is enforced if *any* applying policy - /// enables it, and the access type is resolved to the most restrictive across all applying - /// policies: email verification > password protection > no access control. The `whoCanAccess` - /// values are ordered by restrictiveness, so the highest value wins. + /// When multiple policies apply, a restriction is enforced if *any* applying policy enables it. + /// The enforced access type is resolved to the most restrictive across all applying policies + /// (email verification > password protection > no access control, the `whoCanAccess` values are + /// ordered by restrictiveness and the highest value wins, and the enforced Send type is the + /// most restrictive across all applying policies (per the order text > file > + /// both/unrestricted). /// /// - Parameter sendControlsPolicies: The `sendControls` policies applying to the active user. /// @@ -52,8 +57,30 @@ extension SendPolicyOptions { self.init( allowedDomains: allowedDomains, enforcedAccessType: enforcedAccessType, + enforcedSendType: Self.enforcedSendType(from: policies), isHideEmailDisabled: policies.contains { $0[.disableHideEmail]?.boolValue == true }, isSendDisabled: policies.contains { $0[.disableSend]?.boolValue == true }, ) } + + /// Determines the Send type the user is restricted to from the applying `sendControls` policies. + /// + /// The server sends `allowedSendTypes` as an array of `SendType` raw values (`0` = text, + /// `1` = file); a policy restricts the type when it allows exactly one type. When multiple + /// policies apply, the most restrictive wins per the order text > file > both/unrestricted. + /// + /// - Parameter policies: The `sendControls` policies applying to the active user. + /// - Returns: The enforced `SendType`, or `nil` if both types are allowed (unrestricted). + /// + private static func enforcedSendType(from policies: [Policy]) -> SendType? { + let restrictedTypes = policies.compactMap { policy -> SendType? in + guard let rawTypes = policy[.allowedSendTypes]?.arrayValue else { return nil } + let allowedTypes = Set(rawTypes.compactMap(\.intValue).compactMap(SendType.init(rawValue:))) + return allowedTypes.count == 1 ? allowedTypes.first : nil + } + + if restrictedTypes.contains(.text) { return .text } + if restrictedTypes.contains(.file) { return .file } + return nil + } } diff --git a/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptionsTests.swift b/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptionsTests.swift index 79574f24b0..c934219888 100644 --- a/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptionsTests.swift +++ b/BitwardenShared/Core/Tools/Models/Domain/SendPolicyOptionsTests.swift @@ -62,6 +62,7 @@ struct SendPolicyOptionsTests { let subject = SendPolicyOptions(sendControlsPolicies: []) #expect(subject.allowedDomains.isEmpty) #expect(subject.enforcedAccessType == nil) + #expect(subject.enforcedSendType == nil) #expect(!subject.isHideEmailDisabled) #expect(!subject.isSendDisabled) } @@ -147,4 +148,105 @@ struct SendPolicyOptionsTests { #expect(subject.enforcedAccessType == .specificPeople) #expect(subject.allowedDomains == ["earlier.com"]) } + + /// `init(sendControlsPolicies:)` maps a single-element `allowedSendTypes` array to the enforced + /// Send type. + @Test + func init_sendControlsPolicies_enforcedSendType() { + #expect( + SendPolicyOptions(sendControlsPolicies: [ + .fixture(data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0)])], type: .sendControls), + ]).enforcedSendType == .text, + ) + #expect( + SendPolicyOptions(sendControlsPolicies: [ + .fixture(data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(1)])], type: .sendControls), + ]).enforcedSendType == .file, + ) + } + + /// `init(sendControlsPolicies:)` enforces no Send type when both types are allowed. + @Test + func init_sendControlsPolicies_enforcedSendType_bothAllowed() { + let subject = SendPolicyOptions(sendControlsPolicies: [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0), .int(1)])], + type: .sendControls, + ), + ]) + #expect(subject.enforcedSendType == nil) + } + + /// `init(sendControlsPolicies:)` enforces no Send type when no policy specifies `allowedSendTypes`. + @Test + func init_sendControlsPolicies_enforcedSendType_missing() { + let subject = SendPolicyOptions(sendControlsPolicies: [.fixture(type: .sendControls)]) + #expect(subject.enforcedSendType == nil) + } + + /// `init(sendControlsPolicies:)` enforces no Send type when the `allowedSendTypes` array is empty. + @Test + func init_sendControlsPolicies_enforcedSendType_emptyArray() { + let subject = SendPolicyOptions(sendControlsPolicies: [ + .fixture(data: [PolicyOptionType.allowedSendTypes.rawValue: .array([])], type: .sendControls), + ]) + #expect(subject.enforcedSendType == nil) + } + + /// `init(sendControlsPolicies:)` enforces the most restrictive Send type across applying + /// policies (a single-type restriction wins over an unrestricted "both" policy). + @Test + func init_sendControlsPolicies_enforcedSendType_multiplePolicies_mostRestrictiveWins() { + let subject = SendPolicyOptions(sendControlsPolicies: [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0), .int(1)])], + id: "both", + type: .sendControls, + ), + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0)])], + id: "text-only", + type: .sendControls, + ), + ]) + #expect(subject.enforcedSendType == .text) + } + + /// `init(sendControlsPolicies:)` enforces the file type when a file-only policy applies + /// alongside an unrestricted "both" policy. + @Test + func init_sendControlsPolicies_enforcedSendType_multiplePolicies_fileOnly() { + let subject = SendPolicyOptions(sendControlsPolicies: [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0), .int(1)])], + id: "both", + type: .sendControls, + ), + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(1)])], + id: "file-only", + type: .sendControls, + ), + ]) + #expect(subject.enforcedSendType == .file) + } + + /// `init(sendControlsPolicies:)` resolves a text-only vs file-only conflict to the most + /// restrictive type (text wins per the order text > file > both). + @Test + func init_sendControlsPolicies_enforcedSendType_multiplePolicies_conflictTextWins() { + let subject = SendPolicyOptions(sendControlsPolicies: [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0)])], + id: "text-only", + type: .sendControls, + ), + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(1)])], + id: "file-only", + type: .sendControls, + ), + ]) + #expect(subject.enforcedSendType == .text) + } } diff --git a/BitwardenShared/Core/Tools/Repositories/SendRepository.swift b/BitwardenShared/Core/Tools/Repositories/SendRepository.swift index c9b787797b..8615b409c2 100644 --- a/BitwardenShared/Core/Tools/Repositories/SendRepository.swift +++ b/BitwardenShared/Core/Tools/Repositories/SendRepository.swift @@ -85,9 +85,14 @@ public protocol SendRepository: AnyObject { /// A publisher for all the sends in the user's account. /// + /// - Parameter includeTypesSection: Whether to include the "Types" filter section (the Text/File + /// groups) in the returned sections. Pass `false` to omit it, e.g. when the user is restricted + /// to a single Send type and filtering by type is no longer meaningful. /// - Returns: A publisher for the list of sends in the user's account. /// - func sendListPublisher() async throws -> AsyncThrowingPublisher> + func sendListPublisher( + includeTypesSection: Bool, + ) async throws -> AsyncThrowingPublisher> /// A publisher for a send. /// @@ -278,10 +283,12 @@ class DefaultSendRepository: SendRepository { }.eraseToAnyPublisher().values } - func sendListPublisher() async throws -> AsyncThrowingPublisher> { + func sendListPublisher( + includeTypesSection: Bool, + ) async throws -> AsyncThrowingPublisher> { try await sendService.sendsPublisher() .asyncTryMap { sends in - try await self.sendListSections(from: sends) + try await self.sendListSections(from: sends, includeTypesSection: includeTypesSection) } .eraseToAnyPublisher() .values @@ -312,10 +319,15 @@ class DefaultSendRepository: SendRepository { /// Returns a list of the sections in the vault list from a sync response. /// - /// - Parameter sends: The sends used to build the list of sections. + /// - Parameters: + /// - sends: The sends used to build the list of sections. + /// - includeTypesSection: Whether to include the "Types" filter section (the Text/File groups). /// - Returns: A list of the sections to display in the vault list. /// - private func sendListSections(from sends: [Send]) async throws -> [SendListSection] { + private func sendListSections( + from sends: [Send], + includeTypesSection: Bool, + ) async throws -> [SendListSection] { let sends = try await sends .asyncMap { try await clientService.sends().decrypt(send: $0) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } @@ -324,6 +336,18 @@ class DefaultSendRepository: SendRepository { return [] } + let allSendsSection = SendListSection( + id: "AllSends", + items: sends.compactMap(SendListItem.init), + name: Localizations.allSends, + ) + + // The "Types" filter section is omitted when filtering by type is not meaningful, e.g. when + // the user is restricted to a single Send type by policy. + guard includeTypesSection else { + return [allSendsSection] + } + let fileSendsCount = sends .count(where: { $0.type == .file }) @@ -335,19 +359,13 @@ class DefaultSendRepository: SendRepository { SendListItem(id: "Types.File", itemType: .group(.file, fileSendsCount)), ] - let allItems = sends.compactMap(SendListItem.init) - return [ SendListSection( id: "Types", items: types, name: Localizations.types, ), - SendListSection( - id: "AllSends", - items: allItems, - name: Localizations.allSends, - ), + allSendsSection, ] } diff --git a/BitwardenShared/Core/Tools/Repositories/SendRepositoryTests.swift b/BitwardenShared/Core/Tools/Repositories/SendRepositoryTests.swift index da972fdf56..01424d2c6e 100644 --- a/BitwardenShared/Core/Tools/Repositories/SendRepositoryTests.swift +++ b/BitwardenShared/Core/Tools/Repositories/SendRepositoryTests.swift @@ -317,7 +317,7 @@ class SendRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo func test_sendListPublisher_withoutValues() async throws { sendService.sendsSubject.send([]) - var iterator = try await subject.sendListPublisher().makeAsyncIterator() + var iterator = try await subject.sendListPublisher(includeTypesSection: true).makeAsyncIterator() let sections = try await iterator.next() try assertInlineSnapshot(of: dumpSendListSections(XCTUnwrap(sections)), as: .lines) { @@ -342,7 +342,7 @@ class SendRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo ), ]) - var iterator = try await subject.sendListPublisher().makeAsyncIterator() + var iterator = try await subject.sendListPublisher(includeTypesSection: true).makeAsyncIterator() let sections = try await iterator.next() try assertInlineSnapshot(of: dumpSendListSections(XCTUnwrap(sections)), as: .lines) { @@ -357,6 +357,34 @@ class SendRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo } } + /// `sendListPublisher(includeTypesSection:)` omits the "Types" filter section when + /// `includeTypesSection` is `false`. + func test_sendListPublisher_includeTypesSectionFalse_omitsTypesSection() async throws { + sendService.sendsSubject.send([ + .fixture( + name: "encrypted text name", + text: .init(hidden: false, text: "encrypted text"), + type: .text, + ), + .fixture( + file: .init(fileName: "test.txt", id: "1", size: "123", sizeName: "123 KB"), + name: "encrypted file name", + type: .file, + ), + ]) + + var iterator = try await subject.sendListPublisher(includeTypesSection: false).makeAsyncIterator() + let sections = try await iterator.next() + + try assertInlineSnapshot(of: dumpSendListSections(XCTUnwrap(sections)), as: .lines) { + """ + Section: All Sends + - Send: encrypted file name + - Send: encrypted text name + """ + } + } + /// `sendListPublisher()` returns a publisher for a single send. func test_sendPublisher() async throws { let send1 = Send.fixture(name: "Initial") diff --git a/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockSendRepository.swift b/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockSendRepository.swift index 6f667d1528..4b8bd69820 100644 --- a/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockSendRepository.swift +++ b/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockSendRepository.swift @@ -24,6 +24,7 @@ class MockSendRepository: BitwardenShared.SendRepository { var searchSendSubject = CurrentValueSubject<[SendListItem], Error>([]) var sendListSubject = CurrentValueSubject<[SendListSection], Error>([]) + var sendListPublisherIncludeTypesSection: Bool? var sendSubject = CurrentValueSubject(nil) @@ -102,8 +103,11 @@ class MockSendRepository: BitwardenShared.SendRepository { return searchSendSubject.eraseToAnyPublisher().values } - func sendListPublisher() -> AsyncThrowingPublisher> { - sendListSubject + func sendListPublisher( + includeTypesSection: Bool, + ) -> AsyncThrowingPublisher> { + sendListPublisherIncludeTypesSection = includeTypesSection + return sendListSubject .eraseToAnyPublisher() .values } diff --git a/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift b/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift index 40a2d3b2e6..b44e48edd6 100644 --- a/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift +++ b/BitwardenShared/Core/Vault/Models/Enum/PolicyOptionType.swift @@ -85,6 +85,11 @@ enum PolicyOptionType: String { /// control is email verification ("Specific people"). Encoded as a comma-separated string. case allowedDomains + /// A policy option for the Send types users are allowed to create. Encoded as an array of + /// `SendType` raw values (`0` = text, `1` = file); `[0, 1]` or a missing key means both types + /// are allowed. + case allowedSendTypes + /// A policy option for whether the send should disable the hide email option. case disableHideEmail diff --git a/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift b/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift index 327d136b2d..f51caccfe7 100644 --- a/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift +++ b/BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift @@ -658,6 +658,62 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod XCTAssertNil(options.enforcedAccessType) } + // MARK: - getSendPolicyOptions (enforcedSendType) Tests + + /// `getSendPolicyOptions()` maps a single-type `allowedSendTypes` array to the enforced Send type. + func test_getSendPolicyOptions_enforcedSendType() async { + configService.featureFlagsBool[.sendControls] = true + stateService.activeAccount = .fixture() + organizationService.fetchAllOrganizationsResult = .success([.fixture()]) + policyDataStore.fetchPoliciesResult = .success( + [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(1)])], + type: .sendControls, + ), + ], + ) + + let options = await subject.getSendPolicyOptions() + XCTAssertEqual(options.enforcedSendType, .file) + } + + /// `getSendPolicyOptions()` enforces no Send type when both types are allowed. + func test_getSendPolicyOptions_enforcedSendType_bothAllowed() async { + configService.featureFlagsBool[.sendControls] = true + stateService.activeAccount = .fixture() + organizationService.fetchAllOrganizationsResult = .success([.fixture()]) + policyDataStore.fetchPoliciesResult = .success( + [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(0), .int(1)])], + type: .sendControls, + ), + ], + ) + + let options = await subject.getSendPolicyOptions() + XCTAssertNil(options.enforcedSendType) + } + + /// When the Send Controls feature flag is disabled, `getSendPolicyOptions()` enforces no Send type. + func test_getSendPolicyOptions_enforcedSendType_flagOff() async { + configService.featureFlagsBool[.sendControls] = false + stateService.activeAccount = .fixture() + organizationService.fetchAllOrganizationsResult = .success([.fixture()]) + policyDataStore.fetchPoliciesResult = .success( + [ + .fixture( + data: [PolicyOptionType.allowedSendTypes.rawValue: .array([.int(1)])], + type: .sendControls, + ), + ], + ) + + let options = await subject.getSendPolicyOptions() + XCTAssertNil(options.enforcedSendType) + } + // MARK: - getSendPolicyOptions (isHideEmailDisabled) Tests /// `getSendPolicyOptions()` reports the hide email option disabled when the Send Controls policy diff --git a/BitwardenShared/UI/Tools/Send/Send/SendList/SendListProcessor.swift b/BitwardenShared/UI/Tools/Send/Send/SendList/SendListProcessor.swift index 2b2824bb2e..c815cce048 100644 --- a/BitwardenShared/UI/Tools/Send/Send/SendList/SendListProcessor.swift +++ b/BitwardenShared/UI/Tools/Send/Send/SendList/SendListProcessor.swift @@ -195,7 +195,9 @@ final class SendListProcessor: StateProcessor Date: Fri, 21 Aug 2026 16:21:45 -0500 Subject: [PATCH 2/2] [PM-40460] Prevent creating a Send of a restricted type via the share extension --- .../en.lproj/Localizable.strings | 1 + .../AddEditSendItemProcessor.swift | 16 ++++++++ .../AddEditSendItemProcessorTests.swift | 39 +++++++++++++++++++ .../UI/Vault/Extensions/Alert+Vault.swift | 20 ++++++++++ .../UI/Vault/Extensions/AlertVaultTests.swift | 19 +++++++++ 5 files changed, 95 insertions(+) diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index 6edbc30aac..1fe7b9c9b4 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -427,6 +427,7 @@ "SendOptionsPolicyInEffect" = "One or more organization policies are affecting your Send options."; "SendFilePremiumRequired" = "Free accounts are restricted to sharing text only. A Premium membership is required to use files with Send."; "SendFileEmailVerificationRequired" = "You must verify your email to use files with Send. You can verify your email in the web vault."; +"DueToAnEnterprisePolicyYouCanOnlyCreateXSends" = "Due to an enterprise policy, you can only create %1$@ Sends."; "PasswordPrompt" = "Master password re-prompt"; "PasswordConfirmation" = "Master password confirmation"; "PasswordConfirmationDesc" = "This action is protected, to continue please re-enter your master password to verify your identity."; diff --git a/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessor.swift b/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessor.swift index 44ebef1f39..f6d0ca36d3 100644 --- a/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessor.swift +++ b/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessor.swift @@ -229,6 +229,22 @@ class AddEditSendItemProcessor: // swiftlint:disable:this type_body_length private func loadData() async { state.isSendControlsPolicyEnabled = await services.configService.getFeatureFlag(.sendControls) state.sendPolicyOptions = await services.policyService.getSendPolicyOptions() + + // The share extension sets `state.type` directly from the shared content (file vs. text) + // without going through `SendListProcessor`'s `restrictedSendType`, so it's the only mode + // that can actually reach here with a disallowed type; `.add` is already constrained by + // the Send list's add button before this screen is shown. Only block creating a new Send + // of a disallowed type; editing an existing Send whose type no longer matches the policy + // (e.g. the policy was enforced after the Send was created) should still be allowed. + if state.mode != .edit, + let enforcedSendType = state.sendPolicyOptions.enforcedSendType, + enforcedSendType != state.type { + coordinator.showAlert(.sendTypeRestrictedByPolicy(enforcedSendType) { [weak self] in + self?.coordinator.navigate(to: .cancel) + }) + return + } + if let enforcedAccessType = state.sendPolicyOptions.enforcedAccessType { state.accessType = enforcedAccessType } diff --git a/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessorTests.swift b/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessorTests.swift index 4ac33b7eb9..e18b76f434 100644 --- a/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessorTests.swift +++ b/BitwardenShared/UI/Tools/Send/SendItem/AddEditSendItem/AddEditSendItemProcessorTests.swift @@ -180,6 +180,45 @@ class AddEditSendItemProcessorTests: BitwardenTestCase { // swiftlint:disable:th XCTAssertEqual(subject.state.accessType, .specificPeople) } + /// `perform(_:)` with `loadData` shows an alert and exits the flow when the current Send type + /// conflicts with the policy-enforced Send type. + @MainActor + func test_perform_loadData_enforcedSendType_mismatch() async throws { + subject.state.type = .file + policyService.getSendPolicyOptionsResult.enforcedSendType = .text + await subject.perform(.loadData) + + XCTAssertEqual(coordinator.alertShown, [.sendTypeRestrictedByPolicy(.text) {}]) + XCTAssertFalse(subject.state.hasPremium) + + let alert = try XCTUnwrap(coordinator.alertShown.last) + try await alert.tapAction(title: Localizations.ok) + XCTAssertEqual(coordinator.routes.last, .cancel) + } + + /// `perform(_:)` with `loadData` does not show an alert when the current Send type matches + /// the policy-enforced Send type. + @MainActor + func test_perform_loadData_enforcedSendType_matching() async { + subject.state.type = .text + policyService.getSendPolicyOptionsResult.enforcedSendType = .text + await subject.perform(.loadData) + + XCTAssertTrue(coordinator.alertShown.isEmpty) + } + + /// `perform(_:)` with `loadData` does not show an alert when editing an existing Send whose + /// type no longer matches a policy that was enforced after the Send was created. + @MainActor + func test_perform_loadData_enforcedSendType_editModeMismatch() async { + subject.state.mode = .edit + subject.state.type = .file + policyService.getSendPolicyOptionsResult.enforcedSendType = .text + await subject.perform(.loadData) + + XCTAssertTrue(coordinator.alertShown.isEmpty) + } + /// `perform(_:)` with `loadData` loads whether the Send Controls policy feature flag is enabled. @MainActor func test_perform_loadData_sendControlsPolicyFlag() async { diff --git a/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift b/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift index 54fff73a17..72f8f992e6 100644 --- a/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift +++ b/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift @@ -536,6 +536,26 @@ extension Alert { ) } + /// Returns an alert notifying the user that an enterprise policy restricts them to a single + /// Send type, and that the current action can't be completed. + /// + /// - Parameters: + /// - allowedType: The Send type permitted by policy. + /// - action: A closure to execute when the user acknowledges the alert. + /// - Returns: The alert shown when a Send of the disallowed type would otherwise be created. + static func sendTypeRestrictedByPolicy( + _ allowedType: SendType, + action: @escaping () -> Void, + ) -> Alert { + Alert( + title: nil, + message: Localizations.dueToAnEnterprisePolicyYouCanOnlyCreateXSends(allowedType.localizedName), + alertActions: [ + AlertAction(title: Localizations.ok, style: .default) { _, _ in action() }, + ], + ) + } + /// Returns an alert for when the "Specific People" Send feature is unavailable due to /// lack of Premium subscription. /// diff --git a/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift b/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift index f99fb9f743..3a4a1ca41d 100644 --- a/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift +++ b/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift @@ -670,6 +670,25 @@ class AlertVaultTests: BitwardenTestCase { // swiftlint:disable:this type_body_l XCTAssertEqual(subject.alertActions.first?.style, .default) } + /// `sendTypeRestrictedByPolicy(_:action:)` returns an `Alert` notifying the user that an + /// enterprise policy restricts them to a single Send type. + func test_sendTypeRestrictedByPolicy() async throws { + var called = false + let subject = Alert.sendTypeRestrictedByPolicy(.text) { called = true } + + XCTAssertNil(subject.title) + XCTAssertEqual( + subject.message, + Localizations.dueToAnEnterprisePolicyYouCanOnlyCreateXSends(SendType.text.localizedName), + ) + XCTAssertEqual(subject.alertActions.count, 1) + XCTAssertEqual(subject.alertActions[0].title, Localizations.ok) + XCTAssertEqual(subject.alertActions[0].style, .default) + + try await subject.tapAction(title: Localizations.ok) + XCTAssertTrue(called) + } + /// `specificPeopleUnavailable(action:)` returns an `Alert` notifying the user that the /// "Specific People" Send feature requires Premium. func test_specificPeopleUnavailable() async throws {