Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
///
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
42 changes: 30 additions & 12 deletions BitwardenShared/Core/Tools/Repositories/SendRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyPublisher<[SendListSection], Error>>
func sendListPublisher(
includeTypesSection: Bool,
) async throws -> AsyncThrowingPublisher<AnyPublisher<[SendListSection], Error>>

/// A publisher for a send.
///
Expand Down Expand Up @@ -278,10 +283,12 @@ class DefaultSendRepository: SendRepository {
}.eraseToAnyPublisher().values
}

func sendListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[SendListSection], Error>> {
func sendListPublisher(
includeTypesSection: Bool,
) async throws -> AsyncThrowingPublisher<AnyPublisher<[SendListSection], Error>> {
try await sendService.sendsPublisher()
.asyncTryMap { sends in
try await self.sendListSections(from: sends)
try await self.sendListSections(from: sends, includeTypesSection: includeTypesSection)
}
.eraseToAnyPublisher()
.values
Expand Down Expand Up @@ -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 }
Expand All @@ -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 })

Expand All @@ -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,
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class MockSendRepository: BitwardenShared.SendRepository {
var searchSendSubject = CurrentValueSubject<[SendListItem], Error>([])

var sendListSubject = CurrentValueSubject<[SendListSection], Error>([])
var sendListPublisherIncludeTypesSection: Bool?

var sendSubject = CurrentValueSubject<SendView?, Error>(nil)

Expand Down Expand Up @@ -102,8 +103,11 @@ class MockSendRepository: BitwardenShared.SendRepository {
return searchSendSubject.eraseToAnyPublisher().values
}

func sendListPublisher() -> AsyncThrowingPublisher<AnyPublisher<[SendListSection], Error>> {
sendListSubject
func sendListPublisher(
includeTypesSection: Bool,
) -> AsyncThrowingPublisher<AnyPublisher<[SendListSection], Error>> {
sendListPublisherIncludeTypesSection = includeTypesSection
return sendListSubject
.eraseToAnyPublisher()
.values
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading