Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -422,6 +422,7 @@
"NewSendCreated" = "Send created";
"Custom" = "Custom";
"SendDisabledWarning" = "Due to an enterprise policy, you are only able to delete an existing Send.";
"ThisDateIsEnforcedByYourOrganization" = "This date is enforced by your organization";
"AboutSend" = "About Send";
"HideEmail" = "Hide my email address from recipients";
"SendOptionsPolicyInEffect" = "One or more organization policies are affecting your Send options.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ 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 number of hours from creation the user is required to use as the Send deletion date, or
/// `nil` if the deletion date is unrestricted.
var enforcedDeletionDateHours: Int?

/// The Send type the user is required to use, or `nil` if both types are allowed (unrestricted).
var enforcedSendType: SendType?

Expand All @@ -28,11 +32,15 @@ extension SendPolicyOptions {
/// Creates the Send policy options from the Send Controls policies that apply to the user.
///
/// 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).
/// Additionally:
/// - The enforced access type is resolved to the most restrictive across all applying policies
/// (email verification > password protection > no access control).
/// - The enforced access control (`whoCanAccess`) values are ordered by restrictiveness and the
/// highest value wins.
/// - The enforced Send type is the most restrictive across all applying policies (per the order
/// text > file > both/unrestricted).
/// - The enforced deletion date is the most restrictive (shortest timeframe, i.e. the minimum
/// number of hours) across all applying policies.
///
/// - Parameter sendControlsPolicies: The `sendControls` policies applying to the active user.
///
Expand All @@ -57,6 +65,7 @@ extension SendPolicyOptions {
self.init(
allowedDomains: allowedDomains,
enforcedAccessType: enforcedAccessType,
enforcedDeletionDateHours: policies.compactMap { $0[.deletionHours]?.intValue }.min(),
enforcedSendType: Self.enforcedSendType(from: policies),
isHideEmailDisabled: policies.contains { $0[.disableHideEmail]?.boolValue == true },
isSendDisabled: policies.contains { $0[.disableSend]?.boolValue == true },
Expand Down
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.enforcedDeletionDateHours == nil)
#expect(subject.enforcedSendType == nil)
#expect(!subject.isHideEmailDisabled)
#expect(!subject.isSendDisabled)
Expand Down Expand Up @@ -149,6 +150,43 @@ struct SendPolicyOptionsTests {
#expect(subject.allowedDomains == ["earlier.com"])
}

/// `init(sendControlsPolicies:)` reads the enforced deletion date hours from a policy's
/// `deletionHours` option.
@Test
func init_sendControlsPolicies_enforcedDeletionDateHours() {
let subject = SendPolicyOptions(sendControlsPolicies: [
.fixture(data: [PolicyOptionType.deletionHours.rawValue: .int(168)], type: .sendControls),
])
#expect(subject.enforcedDeletionDateHours == 168)
}

/// `init(sendControlsPolicies:)` enforces no deletion date when no policy specifies
/// `deletionHours`.
@Test
func init_sendControlsPolicies_enforcedDeletionDateHours_missing() {
let subject = SendPolicyOptions(sendControlsPolicies: [.fixture(type: .sendControls)])
#expect(subject.enforcedDeletionDateHours == nil)
}

/// `init(sendControlsPolicies:)` enforces the most restrictive deletion date (the shortest
/// timeframe, i.e. the minimum hours) across applying policies.
@Test
func init_sendControlsPolicies_enforcedDeletionDateHours_multiplePolicies_shortestWins() {
let subject = SendPolicyOptions(sendControlsPolicies: [
.fixture(
data: [PolicyOptionType.deletionHours.rawValue: .int(720)],
id: "thirty-days",
type: .sendControls,
),
.fixture(
data: [PolicyOptionType.deletionHours.rawValue: .int(168)],
id: "seven-days",
type: .sendControls,
),
])
#expect(subject.enforcedDeletionDateHours == 168)
}

/// `init(sendControlsPolicies:)` maps a single-element `allowedSendTypes` array to the enforced
/// Send type.
@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ enum SendDeletionDateType: Menuable {
/// A time period of seven days.
case sevenDays

/// A time period of fourteen days.
case fourteenDays

/// A time period of thirty days.
case thirtyDays

Expand All @@ -37,11 +40,38 @@ enum SendDeletionDateType: Menuable {
case .twoDays: Localizations.xDays(2)
case .threeDays: Localizations.xDays(3)
case .sevenDays: Localizations.xDays(7)
case .fourteenDays: Localizations.xDays(14)
case .thirtyDays: Localizations.xDays(30)
case let .custom(customDate): customDate.dateTimeDisplay
}
}

// MARK: Type Methods

/// Returns the deletion date type matching the given number of hours.
///
/// Maps to a preset case when `hours` matches a known preset (`1`, `24`, `48`, `72`, `168`,
/// `336`, `720`); otherwise returns a `.custom` date `hours` from `originDate`. Used to
/// represent a policy-enforced deletion date supplied as a number of hours.
///
/// - Parameters:
/// - hours: The number of hours from `originDate` at which the Send should be deleted.
/// - originDate: The date the custom-fallback calculation is based on. Defaults to `Date()`.
/// - Returns: The matching `SendDeletionDateType`.
///
static func from(hours: Int, originDate: Date = Date()) -> SendDeletionDateType {
switch hours {
case 1: .oneHour
case 24: .oneDay
case 48: .twoDays
case 72: .threeDays
case 168: .sevenDays
case 336: .fourteenDays
case 720: .thirtyDays
default: .custom(Calendar.current.date(byAdding: .hour, value: hours, to: originDate) ?? originDate)
}
}

// MARK: Methods

/// Calculates the date representation of this value.
Expand All @@ -60,6 +90,8 @@ enum SendDeletionDateType: Menuable {
Calendar.current.date(byAdding: .day, value: 3, to: originDate)
case .sevenDays:
Calendar.current.date(byAdding: .day, value: 7, to: originDate)
case .fourteenDays:
Calendar.current.date(byAdding: .day, value: 14, to: originDate)
case .thirtyDays:
Calendar.current.date(byAdding: .day, value: 30, to: originDate)
case let .custom(customDate):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,46 @@ class SendDeletionDateTypeTests: BitwardenTestCase {
let sevenDays = SendDeletionDateType.sevenDays.calculateDate(from: originDate)
XCTAssertEqual(sevenDays, Date(year: 2023, month: 11, day: 12))

let fourteenDays = SendDeletionDateType.fourteenDays.calculateDate(from: originDate)
XCTAssertEqual(fourteenDays, Date(year: 2023, month: 11, day: 19))

let thirtyDays = SendDeletionDateType.thirtyDays.calculateDate(from: originDate)
XCTAssertEqual(thirtyDays, Date(year: 2023, month: 12, day: 5))

let custom = SendDeletionDateType.custom(customDate).calculateDate(from: originDate)
XCTAssertEqual(custom, customDate)
}

/// `from(hours:)` maps a preset number of hours to the matching preset case.
func test_fromHours_presets() {
let originDate = Date(year: 2023, month: 11, day: 5)
XCTAssertEqual(SendDeletionDateType.from(hours: 1, originDate: originDate), .oneHour)
XCTAssertEqual(SendDeletionDateType.from(hours: 24, originDate: originDate), .oneDay)
XCTAssertEqual(SendDeletionDateType.from(hours: 48, originDate: originDate), .twoDays)
XCTAssertEqual(SendDeletionDateType.from(hours: 72, originDate: originDate), .threeDays)
XCTAssertEqual(SendDeletionDateType.from(hours: 168, originDate: originDate), .sevenDays)
XCTAssertEqual(SendDeletionDateType.from(hours: 336, originDate: originDate), .fourteenDays)
XCTAssertEqual(SendDeletionDateType.from(hours: 720, originDate: originDate), .thirtyDays)
}

/// `from(hours:)` falls back to a custom date `hours` from the origin date when the number of
/// hours doesn't match a preset.
func test_fromHours_customFallback() {
let originDate = Date(year: 2023, month: 11, day: 5)
XCTAssertEqual(
SendDeletionDateType.from(hours: 100, originDate: originDate),
.custom(Date(year: 2023, month: 11, day: 9, hour: 4)),
)
}

/// `localizedName` returns the localized name of the option to display in the menu.
func test_localizedName() {
XCTAssertEqual(SendDeletionDateType.oneHour.localizedName, Localizations.xHours(1))
XCTAssertEqual(SendDeletionDateType.oneDay.localizedName, Localizations.xDays(1))
XCTAssertEqual(SendDeletionDateType.twoDays.localizedName, Localizations.xDays(2))
XCTAssertEqual(SendDeletionDateType.threeDays.localizedName, Localizations.xDays(3))
XCTAssertEqual(SendDeletionDateType.sevenDays.localizedName, Localizations.xDays(7))
XCTAssertEqual(SendDeletionDateType.fourteenDays.localizedName, Localizations.xDays(14))
XCTAssertEqual(SendDeletionDateType.thirtyDays.localizedName, Localizations.xDays(30))

XCTAssertEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ enum PolicyOptionType: String {
/// are allowed.
case allowedSendTypes

/// A policy option for the deletion date users are required to use on Sends, encoded as an
/// `Int` number of hours from creation (e.g. `168` = 7 days). A missing key means the deletion
/// date is not restricted.
case deletionHours

/// A policy option for whether the send should disable the hide email option.
case disableHideEmail

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ class AddEditSendItemProcessor: // swiftlint:disable:this type_body_length
if let enforcedAccessType = state.sendPolicyOptions.enforcedAccessType {
state.accessType = enforcedAccessType
}
if let enforcedDeletionDate = state.policyEnforcedDeletionDate {
state.deletionDate = enforcedDeletionDate
}
state.hasPremium = await services.sendRepository.doesActiveAccountHavePremium()
await refreshProfileState()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,32 @@ class AddEditSendItemProcessorTests: BitwardenTestCase { // swiftlint:disable:th
XCTAssertTrue(coordinator.alertShown.isEmpty)
}

/// `perform(_:)` with `loadData` applies a policy-enforced deletion date, forcing the deletion
/// date and marking it enforced.
@MainActor
func test_perform_loadData_enforcedDeletionDate() async {
await subject.perform(.loadData)
XCTAssertNil(subject.state.policyEnforcedDeletionDate)
XCTAssertFalse(subject.state.isDeletionDateEnforcedByPolicy)

policyService.getSendPolicyOptionsResult.enforcedDeletionDateHours = 168
await subject.perform(.loadData)
XCTAssertEqual(subject.state.policyEnforcedDeletionDate, .sevenDays)
XCTAssertEqual(subject.state.deletionDate, .sevenDays)
XCTAssertTrue(subject.state.isDeletionDateEnforcedByPolicy)
}

/// `perform(_:)` with `loadData` leaves the deletion date unchanged when no deletion date is
/// enforced by policy.
@MainActor
func test_perform_loadData_noEnforcedDeletionDate() async {
subject.state.deletionDate = .oneDay
await subject.perform(.loadData)
XCTAssertNil(subject.state.policyEnforcedDeletionDate)
XCTAssertFalse(subject.state.isDeletionDateEnforcedByPolicy)
XCTAssertEqual(subject.state.deletionDate, .oneDay)
}

/// `perform(_:)` with `loadData` loads whether the Send Controls policy feature flag is enabled.
@MainActor
func test_perform_loadData_sendControlsPolicyFlag() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ struct AddEditSendItemState: Equatable, Sendable {
var availableDeletionDateTypes: [SendDeletionDateType] {
switch mode {
case .add, .shareExtension:
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .thirtyDays]
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .fourteenDays, .thirtyDays]
case .edit:
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .thirtyDays, .custom(customDeletionDate)]
[
.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .fourteenDays, .thirtyDays,
.custom(customDeletionDate),
]
}
}

Expand All @@ -137,6 +140,11 @@ struct AddEditSendItemState: Equatable, Sendable {
sendPolicyOptions.enforcedAccessType != nil
}

/// Whether the deletion date is enforced by policy, which disables the deletion date menu.
var isDeletionDateEnforcedByPolicy: Bool {
sendPolicyOptions.enforcedDeletionDateHours != nil
}

/// Whether sends are disabled via a policy.
var isSendDisabled: Bool {
sendPolicyOptions.isSendDisabled
Expand Down Expand Up @@ -182,6 +190,12 @@ struct AddEditSendItemState: Equatable, Sendable {
sendPolicyOptions.enforcedAccessType
}

/// The deletion date the user is required to use by policy, or `nil` if the deletion date is
/// not restricted by policy.
var policyEnforcedDeletionDate: SendDeletionDateType? {
sendPolicyOptions.enforcedDeletionDateHours.map { SendDeletionDateType.from(hours: $0) }
}

/// Whether the hide-email field should be shown.
///
/// When the Send Controls policy disables hiding the sender's email, the field is hidden
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ class AddEditSendItemStateTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertTrue(subject.isAccessTypeEnforcedByPolicy)
}

// MARK: isDeletionDateEnforcedByPolicy

/// `isDeletionDateEnforcedByPolicy` is `true` when a deletion date is enforced by policy.
func test_isDeletionDateEnforcedByPolicy_enforced() {
let subject = AddEditSendItemState(sendPolicyOptions: SendPolicyOptions(enforcedDeletionDateHours: 168))
XCTAssertTrue(subject.isDeletionDateEnforcedByPolicy)
}

/// `isDeletionDateEnforcedByPolicy` is `false` when no deletion date is enforced by policy.
func test_isDeletionDateEnforcedByPolicy_notEnforced() {
let subject = AddEditSendItemState(sendPolicyOptions: SendPolicyOptions(enforcedDeletionDateHours: nil))
XCTAssertFalse(subject.isDeletionDateEnforcedByPolicy)
}

// MARK: normalizedRecipientEmails

/// `normalizedRecipientEmails` applies all transformations: trim, lowercase, and filter.
Expand Down Expand Up @@ -56,6 +70,20 @@ class AddEditSendItemStateTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertEqual(subject.normalizedRecipientEmails, ["test@example.com", "another@example.com"])
}

// MARK: policyEnforcedDeletionDate

/// `policyEnforcedDeletionDate` maps the enforced hours to the matching deletion date type.
func test_policyEnforcedDeletionDate_enforced() {
let subject = AddEditSendItemState(sendPolicyOptions: SendPolicyOptions(enforcedDeletionDateHours: 168))
XCTAssertEqual(subject.policyEnforcedDeletionDate, .sevenDays)
}

/// `policyEnforcedDeletionDate` is `nil` when no deletion date is enforced by policy.
func test_policyEnforcedDeletionDate_notEnforced() {
let subject = AddEditSendItemState(sendPolicyOptions: SendPolicyOptions(enforcedDeletionDateHours: nil))
XCTAssertNil(subject.policyEnforcedDeletionDate)
}

// MARK: shouldShowTrashIcon

/// `shouldShowTrashIcon(for:)` returns `false` when there's only one empty email field.
Expand Down Expand Up @@ -91,7 +119,7 @@ class AddEditSendItemStateTests: BitwardenTestCase { // swiftlint:disable:this t
let subject = AddEditSendItemState(mode: .add)
XCTAssertEqual(
subject.availableDeletionDateTypes,
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .thirtyDays],
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .fourteenDays, .thirtyDays],
)
}

Expand All @@ -102,7 +130,7 @@ class AddEditSendItemStateTests: BitwardenTestCase { // swiftlint:disable:this t
let subject = AddEditSendItemState(customDeletionDate: deletionDate, mode: .edit)
XCTAssertEqual(
subject.availableDeletionDateTypes,
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .thirtyDays, .custom(deletionDate)],
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .fourteenDays, .thirtyDays, .custom(deletionDate)],
)
}

Expand All @@ -112,7 +140,7 @@ class AddEditSendItemStateTests: BitwardenTestCase { // swiftlint:disable:this t
let subject = AddEditSendItemState(mode: .shareExtension(.singleAccount))
XCTAssertEqual(
subject.availableDeletionDateTypes,
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .thirtyDays],
[.oneHour, .oneDay, .twoDays, .threeDays, .sevenDays, .fourteenDays, .thirtyDays],
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ class AddEditSendItemViewTests: BitwardenTestCase {
XCTAssertEqual(processor.dispatchedActions.last, .deletionDateChanged(.thirtyDays))
}

/// The deletion date menu remains visible but is disabled when the deletion date is enforced
/// by policy.
@MainActor
func test_deletionDateMenu_disabledWhenEnforcedByPolicy() throws {
processor.state.isOptionsExpanded = true
var menuField = try subject.inspect().find(bitwardenMenuField: Localizations.deletionDate)
XCTAssertFalse(menuField.isDisabled())

processor.state.sendPolicyOptions.enforcedDeletionDateHours = 168
menuField = try subject.inspect().find(bitwardenMenuField: Localizations.deletionDate)
XCTAssertTrue(menuField.isDisabled())
}

/// The deletion date field shows the policy helper text when the deletion date is enforced by
/// policy.
@MainActor
func test_deletionDate_helperTextWhenEnforcedByPolicy() throws {
processor.state.isOptionsExpanded = true
processor.state.sendPolicyOptions.enforcedDeletionDateHours = 168
XCTAssertNoThrow(
try subject.inspect().find(text: Localizations.thisDateIsEnforcedByYourOrganization),
)
}

/// Updating the maximum access count stepper sends the `.maximumAccessCountChanged` action.
@MainActor
func test_maximumAccessCountStepper_updated() throws {
Expand Down
Loading
Loading