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
@@ -0,0 +1,32 @@
/// An object that represents a session timeout policy
///
public struct SessionTimeoutPolicy {
// MARK: Properties

/// The action to perform on session timeout.
public let timeoutAction: SessionTimeoutAction?

/// An enumeration of session timeout types to choose from.
public let timeoutType: SessionTimeoutType?

/// An enumeration of session timeout values to choose from.
public let timeoutValue: SessionTimeoutValue?

// MARK: Initialization

/// Initialize `SessionTimeoutPolicy` with the specified values.
///
/// - Parameters:
/// - timeoutAction: The action to perform on session timeout.
/// - timeoutType: The type of session timeout.
/// - timeoutValue: The session timeout value.
public init(
timeoutAction: SessionTimeoutAction?,
timeoutType: SessionTimeoutType?,
timeoutValue: SessionTimeoutValue?,
) {
self.timeoutAction = timeoutAction
self.timeoutType = timeoutType
self.timeoutValue = timeoutValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import BitwardenResources

/// The action to perform on session timeout.
///
public enum SessionTimeoutAction: Int, CaseIterable, Codable, Equatable, Menuable, Sendable {
/// Lock the vault.
case lock = 0

/// Log the user out.
case logout = 1

/// All of the cases to show in the menu.
public static let allCases: [SessionTimeoutAction] = [.lock, .logout]

public var localizedName: String {
switch self {
case .lock:
Localizations.lock
case .logout:
Localizations.logOut
}
}
}
93 changes: 93 additions & 0 deletions BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// MARK: - SessionTimeoutType

/// An enumeration of session timeout types to choose from.
///
public enum SessionTimeoutType: Codable, Equatable, Hashable, Sendable {
/// Time out immediately.
case immediately

/// Time out on app restart.
case onAppRestart

/// Never time out the session.
case never

/// A custom timeout value.
case custom

// MARK: Properties

/// The string representation of a session timeout type.
public var rawValue: String {
switch self {
case .immediately:
"immediately"
case .onAppRestart:
"onAppRestart"
case .never:
"never"
case .custom:
"custom"
}
}

/// A safe string representation of the timeout type.
public var timeoutType: String {
switch self {
case .immediately:
"immediately"
case .onAppRestart:
"on app restart"
case .never:
"never"
case .custom:
"custom"
}
}

// MARK: Initialization

/// Initialize a `SessionTimeoutType` using a string of the raw value.
///
/// - Parameter rawValue: The string representation of the type raw value.
///
public init(rawValue: String?) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Finding 6: Missing test coverage for SessionTimeoutType

This enum lacks dedicated unit tests, particularly for:

  1. The init(rawValue:) method with all possible string values
  2. The legacy "onSystemLock" โ†’ "onAppRestart" mapping (line 70)
  3. The default case handling (line 73) which falls back to .custom
  4. The init(value:) method with all SessionTimeoutValue cases

These initialization paths are critical for policy enforcement compatibility with legacy servers. Add tests in a new file: BitwardenKit/Core/Platform/Models/Enum/SessionTimeoutTypeTests.swift

switch rawValue {
case "custom":
self = .custom
case "immediately":
self = .immediately
case "never":
self = .never
case "onAppRestart",
"onSystemLock":
self = .onAppRestart
default:
self = .custom
}
}

/// Initialize a `SessionTimeoutType` using a SessionTimeoutValue that belongs to that type.
///
/// - Parameter value: The SessionTimeoutValue that belongs to the type.
///
public init(value: SessionTimeoutValue) {
switch value {
case .custom:
self = .custom
case .immediately:
self = .immediately
case .never:
self = .never
case .onAppRestart:
self = .onAppRestart
case .fifteenMinutes,
.fiveMinutes,
.fourHours,
.oneHour,
.oneMinute,
.thirtyMinutes:
self = .custom
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import BitwardenKit
import XCTest

final class SessionTimeoutTypeTests: BitwardenTestCase {
// MARK: Tests

/// `init(rawValue:)` returns the correct case for the given raw value string.
func test_initFromRawValue() {
XCTAssertEqual(SessionTimeoutType.immediately, SessionTimeoutType(rawValue: "immediately"))
XCTAssertEqual(SessionTimeoutType.onAppRestart, SessionTimeoutType(rawValue: "onAppRestart"))
// `onSystemLock` value maps to `onAppRestart` on mobile.
XCTAssertEqual(SessionTimeoutType.onAppRestart, SessionTimeoutType(rawValue: "onSystemLock"))
XCTAssertEqual(SessionTimeoutType.never, SessionTimeoutType(rawValue: "never"))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: "custom"))
// `nil` value maps to `custom` on mobile in support to legacy.
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(rawValue: nil))
}

/// `init(value:)` returns the correct case for the given `SessionTimeoutValue`.
func test_initFromSessionTimeoutValue() {
XCTAssertEqual(SessionTimeoutType.immediately, SessionTimeoutType(value: .immediately))
XCTAssertEqual(SessionTimeoutType.onAppRestart, SessionTimeoutType(value: .onAppRestart))
XCTAssertEqual(SessionTimeoutType.never, SessionTimeoutType(value: .never))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .custom(123)))
}

/// `init(value:)` returns `.custom` for all predefined timeout values.
func test_initFromSessionTimeoutValue_predefined() {
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .oneMinute))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .fiveMinutes))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .fifteenMinutes))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .thirtyMinutes))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .oneHour))
XCTAssertEqual(SessionTimeoutType.custom, SessionTimeoutType(value: .fourHours))
}

/// `rawValue` returns the correct string values.
func test_rawValues() {
XCTAssertEqual(SessionTimeoutType.immediately.rawValue, "immediately")
XCTAssertEqual(SessionTimeoutType.onAppRestart.rawValue, "onAppRestart")
XCTAssertEqual(SessionTimeoutType.never.rawValue, "never")
XCTAssertEqual(SessionTimeoutType.custom.rawValue, "custom")
}

/// `timeoutType` returns the correct string representation values.
func test_timeoutType() {
XCTAssertEqual(SessionTimeoutType.immediately.timeoutType, "immediately")
XCTAssertEqual(SessionTimeoutType.onAppRestart.timeoutType, "on app restart")
XCTAssertEqual(SessionTimeoutType.never.timeoutType, "never")
XCTAssertEqual(SessionTimeoutType.custom.timeoutType, "custom")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public struct SettingsPickerField: View {
/// The custom session timeout value.
let customTimeoutValue: String

/// The footer text displayed below the toggle.
let footer: String?

/// Whether the menu field should have a bottom divider.
let hasDivider: Bool

Expand Down Expand Up @@ -68,6 +71,17 @@ public struct SettingsPickerField: View {
.padding(.leading, 16)
}
}

if footer != nil {
Group {
if let footer {
Text(footer)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: SharedAsset.Colors.textSecondary))
}
}
.padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
}
}
.background(SharedAsset.Colors.backgroundSecondary.swiftUIColor)
}
Expand All @@ -78,20 +92,23 @@ public struct SettingsPickerField: View {
///
/// - Parameters:
/// - title: The title of the field.
/// - footer: The footer text displayed below the menu field.
/// - customTimeoutValue: The custom session timeout value.
/// - pickerValue: The date picker value.
/// - hasDivider: Whether or not the field has a bottom edge divider.
/// - customTimeoutAccessibilityLabel: The accessibility label used for the custom timeout value.
///
public init(
title: String,
footer: String? = nil,
customTimeoutValue: String,
pickerValue: Binding<Int>,
hasDivider: Bool = true,
customTimeoutAccessibilityLabel: String,
) {
self.customTimeoutAccessibilityLabel = customTimeoutAccessibilityLabel
self.customTimeoutValue = customTimeoutValue
self.footer = footer
self.hasDivider = hasDivider
_pickerValue = pickerValue
self.title = title
Expand All @@ -103,6 +120,7 @@ public struct SettingsPickerField: View {
#Preview {
SettingsPickerField(
title: "Custom",
footer: nil,
customTimeoutValue: "1:00",
pickerValue: .constant(1),
customTimeoutAccessibilityLabel: "one hour, zero minutes",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1275,3 +1275,6 @@
"TheNewRecommendedEncryptionSettingsDescriptionLong" = "The new recommended encryption settings will improve your account security. Enter your master password to update now.";
"Updating" = "Updatingโ€ฆ";
"EncryptionSettingsUpdated" = "Encryption settings updated";
"ThisSettingIsManagedByYourOrganization" = "This setting is managed by your organization.";
"YourOrganizationHasSetTheDefaultSessionTimeoutToX" = "Your organization has set the default session timeout to %1$@.";
"YourOrganizationHasSetTheDefaultSessionTimeoutToXAndY" = "Your organization has set the default session timeout to %1$@ and %2$@.";
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ enum PolicyOptionType: String {
/// A policy option for the minimum number of special characters.
case minSpecial

/// A policy option for the vault timeout type.
case type

/// A policy option for whether to include lowercase characters.
case useLower

Expand Down
28 changes: 21 additions & 7 deletions BitwardenShared/Core/Vault/Services/PolicyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ protocol PolicyService: AnyObject {
///
/// - Returns: The timeout value in minutes, and the action to take upon timeout.
///
func fetchTimeoutPolicyValues() async throws -> (action: SessionTimeoutAction?, value: Int)?
func fetchTimeoutPolicyValues() async throws -> SessionTimeoutPolicy?

/// Go through current users policy, filter them and build a master password policy options based on enabled policy.
/// - Returns: Optional `MasterPasswordPolicyOptions` if it exist.
Expand Down Expand Up @@ -266,25 +266,39 @@ extension DefaultPolicyService {
return true
}

func fetchTimeoutPolicyValues() async throws -> (action: SessionTimeoutAction?, value: Int)? {
func fetchTimeoutPolicyValues() async throws -> SessionTimeoutPolicy? {
let policies = await policiesApplyingToUser(.maximumVaultTimeout)
guard !policies.isEmpty else { return nil }

var timeoutAction: SessionTimeoutAction?
var timeoutValue = 0
var timeoutType: SessionTimeoutType?
var timeoutValue: SessionTimeoutValue?

for policy in policies {
guard let policyTimeoutValue = policy[.minutes]?.intValue else { continue }
timeoutValue = policyTimeoutValue
timeoutValue = SessionTimeoutValue(rawValue: policyTimeoutValue)

// Legacy servers may not send this value.
// In that case, we will present to the user the custom type.
if policy[.type] != nil {
timeoutType = SessionTimeoutType(rawValue: policy[.type]?.stringValue)
}

// If the policy's timeout action is not lock or logOut, there is no policy timeout action.
// In that case, we would present both timeout action options to the user.
guard let action = policy[.action]?.stringValue, action == "lock" || action == "logOut" else {
return (nil, timeoutValue)
return SessionTimeoutPolicy(timeoutAction: nil, timeoutType: timeoutType, timeoutValue: timeoutValue)
}
switch action {
case "lock":
timeoutAction = .lock
case "logOut":
timeoutAction = .logout
default:
timeoutAction = nil
}
timeoutAction = action == "lock" ? .lock : .logout
}
return (timeoutAction, timeoutValue)
return SessionTimeoutPolicy(timeoutAction: timeoutAction, timeoutType: timeoutType, timeoutValue: timeoutValue)
}

func getMasterPasswordPolicyOptions() async throws -> MasterPasswordPolicyOptions? {
Expand Down
33 changes: 28 additions & 5 deletions BitwardenShared/Core/Vault/Services/PolicyServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod

let maximumTimeoutPolicy = Policy.fixture(
data: [
PolicyOptionType.minutes.rawValue: .int(60),
PolicyOptionType.action.rawValue: .string("lock"),
PolicyOptionType.minutes.rawValue: .int(60),
PolicyOptionType.type.rawValue: .string("custom"),
],
type: .maximumVaultTimeout,
)
Expand All @@ -40,6 +41,15 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod
data: [PolicyOptionType.minutes.rawValue: .int(60)],
type: .maximumVaultTimeout,
)

let maximumTimeoutPolicyLogout = Policy.fixture(
data: [
PolicyOptionType.action.rawValue: .string("logOut"),
PolicyOptionType.minutes.rawValue: .int(60),
PolicyOptionType.type.rawValue: .string("custom"),
],
type: .maximumVaultTimeout,
)

let passwordGeneratorPolicy = Policy.fixture(
data: [
Expand Down Expand Up @@ -387,8 +397,21 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod

let policyValues = try await subject.fetchTimeoutPolicyValues()

XCTAssertEqual(policyValues?.value, 60)
XCTAssertEqual(policyValues?.action, .lock)
XCTAssertEqual(policyValues?.timeoutValue?.rawValue, 60)
XCTAssertEqual(policyValues?.timeoutAction, .lock)
}

/// `fetchTimeoutPolicyValues()` fetches timeout values when the policy contains data.
func test_fetchTimeoutPolicyValues_logout() async throws {
stateService.activeAccount = .fixture()
organizationService.fetchAllOrganizationsResult = .success([.fixture()])
policyDataStore.fetchPoliciesResult = .success([maximumTimeoutPolicyLogout])

let policyValues = try await subject.fetchTimeoutPolicyValues()

XCTAssertEqual(policyValues?.timeoutAction, .logout)
XCTAssertEqual(policyValues?.timeoutType, .custom)
XCTAssertEqual(policyValues?.timeoutValue?.rawValue, 60)
}

/// `fetchTimeoutPolicyValues()` returns `nil` if the user is exempt from policies in the organization.
Expand All @@ -411,8 +434,8 @@ class PolicyServiceTests: BitwardenTestCase { // swiftlint:disable:this type_bod

let policyValues = try await subject.fetchTimeoutPolicyValues()

XCTAssertEqual(policyValues?.value, 60)
XCTAssertNil(policyValues?.action)
XCTAssertEqual(policyValues?.timeoutValue?.rawValue, 60)
XCTAssertNil(policyValues?.timeoutAction)
}

/// `organizationsApplyingPolicyToUser(_:)` returns the organization IDs which apply the policy.
Expand Down
Loading