diff --git a/BitwardenKit/Core/Platform/Models/Enum/DeviceType.swift b/BitwardenKit/Core/Platform/Models/Enum/DeviceType.swift new file mode 100644 index 0000000000..15dd90a9d8 --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Enum/DeviceType.swift @@ -0,0 +1,39 @@ +// MARK: - DeviceType + +/// The type of device used to access the vault. +/// +public enum DeviceType: Int, Codable, Hashable, Sendable { + case android = 0 + case iOS = 1 + case chromeExtension = 2 + case firefoxExtension = 3 + case operaExtension = 4 + case edgeExtension = 5 + case windowsDesktop = 6 + case macOsDesktop = 7 + case linuxDesktop = 8 + case chromeBrowser = 9 + case firefoxBrowser = 10 + case operaBrowser = 11 + case edgeBrowser = 12 + case ieBrowser = 13 + case unknownBrowser = 14 + case androidAmazon = 15 + case uwp = 16 + case safariBrowser = 17 + case vivaldiBrowser = 18 + case vivaldiExtension = 19 + case safariExtension = 20 + case sdk = 21 + case server = 22 + case windowsCLI = 23 + case macOsCLI = 24 + case linuxCLI = 25 + case duckDuckGoBrowser = 26 +} + +// MARK: - DefaultValueProvider + +extension DeviceType: DefaultValueProvider { + public static var defaultValue: DeviceType { .unknownBrowser } +} diff --git a/BitwardenKit/Core/Platform/Models/Enum/DeviceTypeTests.swift b/BitwardenKit/Core/Platform/Models/Enum/DeviceTypeTests.swift new file mode 100644 index 0000000000..ed50c24b07 --- /dev/null +++ b/BitwardenKit/Core/Platform/Models/Enum/DeviceTypeTests.swift @@ -0,0 +1,12 @@ +import BitwardenKit +import XCTest + +class DeviceTypeTests: BitwardenTestCase { + // MARK: Tests + + /// `defaultValue` returns the default value for the type if an invalid or missing value is + /// received when decoding the type. + func test_defaultValue() { + XCTAssertEqual(DeviceType.defaultValue, .unknownBrowser) + } +} diff --git a/BitwardenKit/Core/Platform/Services/API/Handlers/DefaultHeadersRequestHandler.swift b/BitwardenKit/Core/Platform/Services/API/Handlers/DefaultHeadersRequestHandler.swift index 6c6f9f5dc0..e60739bff3 100644 --- a/BitwardenKit/Core/Platform/Services/API/Handlers/DefaultHeadersRequestHandler.swift +++ b/BitwardenKit/Core/Platform/Services/API/Handlers/DefaultHeadersRequestHandler.swift @@ -29,7 +29,7 @@ public final class DefaultHeadersRequestHandler: RequestHandler { public func handle(_ request: inout HTTPRequest) async throws -> HTTPRequest { request.headers["Bitwarden-Client-Name"] = Constants.clientType request.headers["Bitwarden-Client-Version"] = appVersion - request.headers["Device-Type"] = String(Constants.deviceType) + request.headers["Device-Type"] = String(Constants.deviceType.rawValue) request.headers["User-Agent"] = userAgentBuilder.value return request diff --git a/BitwardenKit/Core/Platform/Utilities/Constants.swift b/BitwardenKit/Core/Platform/Utilities/Constants.swift index d6ffc806e5..687a0a8215 100644 --- a/BitwardenKit/Core/Platform/Utilities/Constants.swift +++ b/BitwardenKit/Core/Platform/Utilities/Constants.swift @@ -3,9 +3,6 @@ import Foundation /// A type alias for the client type. public typealias ClientType = String -/// A type alias for the device type. -public typealias DeviceType = Int - // MARK: - Constants /// Constant values reused throughout the app. @@ -14,8 +11,8 @@ public enum Constants { /// The client type corresponding to the app. public static let clientType: ClientType = "mobile" - /// The device type, iOS = 1. - public static let deviceType: DeviceType = 1 + /// The device type for this app. + public static let deviceType: DeviceType = .iOS /// The number of days that a flight recorder log will remain on the device after the end date /// before being automatically deleted. diff --git a/BitwardenKit/Core/Platform/Utilities/DefaultValue.swift b/BitwardenKit/Core/Platform/Utilities/DefaultValue.swift index 73fcc17da3..e541258553 100644 --- a/BitwardenKit/Core/Platform/Utilities/DefaultValue.swift +++ b/BitwardenKit/Core/Platform/Utilities/DefaultValue.swift @@ -86,6 +86,10 @@ extension DefaultValue: Equatable where T: Equatable {} extension DefaultValue: Hashable where T: Hashable {} +// MARK: - Sendable + +extension DefaultValue: Sendable where T: Sendable {} + // MARK: - KeyedDecodingContainer public extension KeyedDecodingContainer { diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index 02107ed7d6..fe6da35c7d 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -1169,3 +1169,18 @@ "LineOfCredit" = "Line of credit"; "MoneyMarket" = "Money market"; "Savings" = "Savings"; +"Today" = "Today"; +"PastSevenDays" = "Past 7 days"; +"PastFourteenDays" = "Past 14 days"; +"PastThirtyDays" = "Past 30 days"; +"OverThirtyDaysAgo" = "Over 30 days ago"; +"Unknown" = "Unknown"; +/* Device display name. %1$@ = category (e.g. "Mobile"), %2$@ = platform (e.g. "iOS"). */ +"DeviceDisplayName" = "%1$@ - %2$@"; +"Mobile" = "Mobile"; +"BrowserExtension" = "Browser extension"; +"WebVaultDeviceType" = "Web vault"; +"Desktop" = "Desktop"; +"Cli" = "CLI"; +"Sdk" = "SDK"; +"Server" = "Server"; diff --git a/BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift b/BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift new file mode 100644 index 0000000000..688b2c092e --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift @@ -0,0 +1,74 @@ +import BitwardenKit +import Foundation + +// MARK: - DeviceListItem + +/// A UI-friendly model representing a device in the device management list. +/// +struct DeviceListItem: Equatable, Identifiable, Sendable { + // MARK: Properties + + /// The activity status of the device. + let activityStatus: DeviceActivityStatus + + /// The type of the device. + let deviceType: DeviceType + + /// The display name of the device. + let displayName: String + + /// The date of the first login on this device. + let firstLogin: Date + + /// Whether the device has a pending login request. + /// + /// Computed from `pendingRequest` — `true` when a pending request is present. + var hasPendingRequest: Bool { pendingRequest != nil } + + /// The server-assigned UUID that uniquely identifies this device record across all users. + let id: String + + /// The client-generated UUID embedded in the app on this device, used for push notifications + /// and device recognition. + let identifier: String + + /// Whether this is the current session's device. + var isCurrentSession: Bool + + /// Whether the device is trusted. + let isTrusted: Bool + + /// The date of the last activity on this device. + let lastActivityDate: Date? + + /// The most recent pending login request for this device, if any. + var pendingRequest: LoginRequest? +} + +// MARK: - DeviceListItem Extension + +extension DeviceListItem { + // MARK: Initialization + + /// Initializes a `DeviceListItem` from a `DeviceResponse`. + /// + /// - Parameters: + /// - device: The device response from the API. + /// - timeProvider: The time provider to use for calculating the activity status. + /// + init( + device: DeviceResponse, + timeProvider: TimeProvider, + ) { + activityStatus = DeviceActivityStatus(from: device.lastActivityDate, timeProvider: timeProvider) + deviceType = device.type + displayName = device.type.displayName + firstLogin = device.creationDate + id = device.id + identifier = device.identifier + isCurrentSession = false + isTrusted = device.isTrusted + lastActivityDate = device.lastActivityDate + pendingRequest = nil + } +} diff --git a/BitwardenShared/Core/Auth/Models/Domain/Fixtures/DeviceListItem+Fixtures.swift b/BitwardenShared/Core/Auth/Models/Domain/Fixtures/DeviceListItem+Fixtures.swift new file mode 100644 index 0000000000..149206df4c --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Domain/Fixtures/DeviceListItem+Fixtures.swift @@ -0,0 +1,32 @@ +import BitwardenKit +import Foundation + +@testable import BitwardenShared + +extension DeviceListItem { + static func fixture( + activityStatus: DeviceActivityStatus = .today, + deviceType: DeviceType = .iOS, + displayName: String = "Mobile - iOS", + firstLogin: Date = Date(timeIntervalSince1970: 1_704_067_200), + id: String = "device-id-1", + identifier: String = "device-identifier-1", + isCurrentSession: Bool = false, + isTrusted: Bool = true, + lastActivityDate: Date? = Date(timeIntervalSince1970: 1_718_452_200), + pendingRequest: LoginRequest? = nil, + ) -> DeviceListItem { + DeviceListItem( + activityStatus: activityStatus, + deviceType: deviceType, + displayName: displayName, + firstLogin: firstLogin, + id: id, + identifier: identifier, + isCurrentSession: isCurrentSession, + isTrusted: isTrusted, + lastActivityDate: lastActivityDate, + pendingRequest: pendingRequest, + ) + } +} diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift new file mode 100644 index 0000000000..9bedc327fe --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift @@ -0,0 +1,95 @@ +import BitwardenKit +import BitwardenResources +import Foundation + +// MARK: - DeviceActivityStatus + +/// An enumeration representing the activity status of a device based on its last activity date. +/// +enum DeviceActivityStatus: Equatable, Sendable { + /// The device was active last week. + case lastWeek + + /// The device was active over 30 days ago. + case overThirtyDaysAgo + + /// The device was active 14 to 29 days ago (but not this or last week). + case thisMonth + + /// The device was active this week (but not today). + case thisWeek + + /// The device was active today. + case today + + /// The device's activity status is unknown. + case unknown + + // MARK: Properties + + /// The localized display string for the activity status. + var localizedString: String { + switch self { + case .lastWeek: + Localizations.pastFourteenDays + case .overThirtyDaysAgo: + Localizations.overThirtyDaysAgo + case .thisMonth: + Localizations.pastThirtyDays + case .thisWeek: + Localizations.pastSevenDays + case .today: + Localizations.today + case .unknown: + Localizations.unknown + } + } + + // MARK: Initialization + + /// Initializes a `DeviceActivityStatus` from an optional date. + /// + /// - Parameters: + /// - date: The last activity date of the device. + /// - timeProvider: The time provider to use for calculating the status. + /// + init(from date: Date?, timeProvider: TimeProvider) { + guard let date else { + self = .unknown + return + } + + let now = timeProvider.presentTime + + guard date <= now else { + self = .unknown + return + } + + let calendar = Calendar.current + let startOfDate = calendar.startOfDay(for: date) + let startOfToday = calendar.startOfDay(for: now) + + // `.day` is always non-nil when explicitly requested via `dateComponents([.day]:)`; + // the guard is defensive. + guard let daysDifference = calendar.dateComponents([.day], from: startOfDate, to: startOfToday).day else { + self = .unknown + return + } + + // Bucket boundaries mirror the Android app's day-count logic exactly, so both platforms + // show identical "last active" labels for the same date. + switch daysDifference { + case 0: + self = .today + case 1 ..< 7: + self = .thisWeek + case 7 ..< 14: + self = .lastWeek + case 14 ..< 30: + self = .thisMonth + default: + self = .overThirtyDaysAgo + } + } +} diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatusTests.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatusTests.swift new file mode 100644 index 0000000000..82e730a524 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatusTests.swift @@ -0,0 +1,102 @@ +import BitwardenKitMocks +import BitwardenResources +import Foundation +import Testing + +@testable import BitwardenShared + +// MARK: - DeviceActivityStatusTests + +struct DeviceActivityStatusTests { + // MARK: Properties + + var timeProvider: MockTimeProvider + + // MARK: Initialization + + init() { + timeProvider = MockTimeProvider(.mockTime(Date(timeIntervalSince1970: 1_718_000_000))) + } + + // MARK: Tests + + /// `init(from:timeProvider:)` returns `.unknown` when the date is nil. + @Test + func init_nilDate() { + #expect(DeviceActivityStatus(from: nil, timeProvider: timeProvider) == .unknown) + } + + /// `init(from:timeProvider:)` returns `.unknown` when the date is in the future. + @Test + func init_futureDate() { + let futureDate = timeProvider.presentTime.addingTimeInterval(3600) + #expect(DeviceActivityStatus(from: futureDate, timeProvider: timeProvider) == .unknown) + } + + /// `init(from:timeProvider:)` returns `.today` when the date is today (0 days ago). + @Test + func init_today() { + let date = timeProvider.presentTime.addingTimeInterval(-3600) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .today) + } + + /// `init(from:timeProvider:)` returns `.thisWeek` at the 1-day boundary. + @Test + func init_thisWeek_lowerBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -1, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .thisWeek) + } + + /// `init(from:timeProvider:)` returns `.thisWeek` at the 6-day boundary. + @Test + func init_thisWeek_upperBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -6, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .thisWeek) + } + + /// `init(from:timeProvider:)` returns `.lastWeek` at the 7-day boundary. + @Test + func init_lastWeek_lowerBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -7, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .lastWeek) + } + + /// `init(from:timeProvider:)` returns `.lastWeek` at the 13-day boundary. + @Test + func init_lastWeek_upperBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -13, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .lastWeek) + } + + /// `init(from:timeProvider:)` returns `.thisMonth` at the 14-day boundary. + @Test + func init_thisMonth_lowerBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -14, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .thisMonth) + } + + /// `init(from:timeProvider:)` returns `.thisMonth` at the 29-day boundary. + @Test + func init_thisMonth_upperBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -29, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .thisMonth) + } + + /// `init(from:timeProvider:)` returns `.overThirtyDaysAgo` at the 30-day boundary. + @Test + func init_overThirtyDaysAgo_lowerBound() throws { + let date = try #require(Calendar.current.date(byAdding: .day, value: -30, to: timeProvider.presentTime)) + #expect(DeviceActivityStatus(from: date, timeProvider: timeProvider) == .overThirtyDaysAgo) + } + + /// `localizedString` returns the correct value for each status. + @Test + func localizedString() { + #expect(DeviceActivityStatus.today.localizedString == Localizations.today) + #expect(DeviceActivityStatus.thisWeek.localizedString == Localizations.pastSevenDays) + #expect(DeviceActivityStatus.lastWeek.localizedString == Localizations.pastFourteenDays) + #expect(DeviceActivityStatus.thisMonth.localizedString == Localizations.pastThirtyDays) + #expect(DeviceActivityStatus.overThirtyDaysAgo.localizedString == Localizations.overThirtyDaysAgo) + #expect(DeviceActivityStatus.unknown.localizedString == Localizations.unknown) + } +} diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift new file mode 100644 index 0000000000..59d968eae8 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategory.swift @@ -0,0 +1,140 @@ +import BitwardenKit +import BitwardenResources + +// MARK: - DeviceTypeCategory + +/// The category of a device type. +/// +enum DeviceTypeCategory: Sendable { + case cli + case desktop + case `extension` + case mobile + case sdk + case server + case unknown + case webApp + + /// The localized display name for the category. + var displayName: String { + switch self { + case .cli: + Localizations.cli + case .desktop: + Localizations.desktop + case .extension: + Localizations.browserExtension + case .mobile: + Localizations.mobile + case .sdk: + Localizations.sdk + case .server: + Localizations.server + case .unknown: + Localizations.unknown + case .webApp: + Localizations.webVaultDeviceType + } + } +} + +// MARK: - DeviceType Extension + +extension DeviceType { + // MARK: Properties + + /// The category of the device type. + var category: DeviceTypeCategory { + switch self { + case .android, .androidAmazon, .iOS: + .mobile + case .chromeExtension, .edgeExtension, .firefoxExtension, .operaExtension, + .safariExtension, .vivaldiExtension: + .extension + case .chromeBrowser, .duckDuckGoBrowser, .edgeBrowser, .firefoxBrowser, + .ieBrowser, .operaBrowser, .safariBrowser, .vivaldiBrowser: + .webApp + case .linuxDesktop, .macOsDesktop, .uwp, .windowsDesktop: + .desktop + case .linuxCLI, .macOsCLI, .windowsCLI: + .cli + case .sdk: + .sdk + case .server: + .server + case .unknownBrowser: + .unknown + } + } + + /// The platform name for the device type. + var platform: String { + switch self { + case .android: + "Android" + case .iOS: + "iOS" + case .androidAmazon: + "Amazon" + case .chromeBrowser, .chromeExtension: + "Chrome" + case .firefoxBrowser, .firefoxExtension: + "Firefox" + case .operaBrowser, .operaExtension: + "Opera" + case .edgeBrowser, .edgeExtension: + "Edge" + case .vivaldiBrowser, .vivaldiExtension: + "Vivaldi" + case .safariBrowser, .safariExtension: + "Safari" + case .ieBrowser: + "Internet Explorer" + case .duckDuckGoBrowser: + "DuckDuckGo" + case .unknownBrowser: + "" + case .windowsCLI, .windowsDesktop: + "Windows" + case .macOsCLI, .macOsDesktop: + "macOS" + case .linuxCLI, .linuxDesktop: + "Linux" + case .uwp: + "Windows UWP" + case .sdk, .server: + "" + } + } + + /// The display name for the device type, combining category and platform. + var displayName: String { + guard !platform.isEmpty else { + return category.displayName + } + return Localizations.deviceDisplayName(category.displayName, platform) + } + + /// A match key used to correlate this device type with a pending login request's + /// `requestDeviceType` string. Differs from `platform` for extension types so that + /// e.g. `chromeExtension` ("Chrome Extension") is not confused with `chromeBrowser` + /// ("Chrome") when both are active simultaneously. + var pendingRequestMatchKey: String { + switch self { + case .chromeExtension: + "Chrome Extension" + case .firefoxExtension: + "Firefox Extension" + case .operaExtension: + "Opera Extension" + case .edgeExtension: + "Edge Extension" + case .safariExtension: + "Safari Extension" + case .vivaldiExtension: + "Vivaldi Extension" + default: + platform + } + } +} diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift new file mode 100644 index 0000000000..5dbb1aa672 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceTypeCategoryTests.swift @@ -0,0 +1,134 @@ +import BitwardenKit +import BitwardenResources +import Testing + +@testable import BitwardenShared + +// MARK: - DeviceTypeCategoryTests + +struct DeviceTypeCategoryTests { + // MARK: Tests + + /// `displayName` returns the localized string for each category. + @Test(arguments: [ + (DeviceTypeCategory.cli, Localizations.cli), + (DeviceTypeCategory.desktop, Localizations.desktop), + (DeviceTypeCategory.extension, Localizations.browserExtension), + (DeviceTypeCategory.mobile, Localizations.mobile), + (DeviceTypeCategory.sdk, Localizations.sdk), + (DeviceTypeCategory.server, Localizations.server), + (DeviceTypeCategory.unknown, Localizations.unknown), + (DeviceTypeCategory.webApp, Localizations.webVaultDeviceType), + ]) + func displayName(category: DeviceTypeCategory, expected: String) { + #expect(category.displayName == expected) + } +} + +// MARK: - DeviceType Extension Tests + +struct DeviceTypeCategoryDeviceTypeExtensionTests { + // MARK: Tests + + /// `category` returns the correct category for each device type. + @Test(arguments: [ + (DeviceType.android, DeviceTypeCategory.mobile), + (.androidAmazon, .mobile), + (.iOS, .mobile), + (.chromeExtension, .extension), + (.edgeExtension, .extension), + (.firefoxExtension, .extension), + (.operaExtension, .extension), + (.safariExtension, .extension), + (.vivaldiExtension, .extension), + (.chromeBrowser, .webApp), + (.duckDuckGoBrowser, .webApp), + (.edgeBrowser, .webApp), + (.firefoxBrowser, .webApp), + (.ieBrowser, .webApp), + (.operaBrowser, .webApp), + (.safariBrowser, .webApp), + (.vivaldiBrowser, .webApp), + (.linuxDesktop, .desktop), + (.macOsDesktop, .desktop), + (.uwp, .desktop), + (.windowsDesktop, .desktop), + (.linuxCLI, .cli), + (.macOsCLI, .cli), + (.windowsCLI, .cli), + (.sdk, .sdk), + (.server, .server), + (.unknownBrowser, .unknown), + ]) + func category(type: DeviceType, expected: DeviceTypeCategory) { + #expect(type.category == expected) + } + + /// `platform` returns the correct platform name for each device type. + @Test(arguments: [ + (DeviceType.android, "Android"), + (.iOS, "iOS"), + (.androidAmazon, "Amazon"), + (.chromeBrowser, "Chrome"), + (.chromeExtension, "Chrome"), + (.firefoxBrowser, "Firefox"), + (.firefoxExtension, "Firefox"), + (.operaBrowser, "Opera"), + (.operaExtension, "Opera"), + (.edgeBrowser, "Edge"), + (.edgeExtension, "Edge"), + (.vivaldiBrowser, "Vivaldi"), + (.vivaldiExtension, "Vivaldi"), + (.safariBrowser, "Safari"), + (.safariExtension, "Safari"), + (.ieBrowser, "Internet Explorer"), + (.duckDuckGoBrowser, "DuckDuckGo"), + (.unknownBrowser, ""), + (.windowsCLI, "Windows"), + (.windowsDesktop, "Windows"), + (.macOsCLI, "macOS"), + (.macOsDesktop, "macOS"), + (.linuxCLI, "Linux"), + (.linuxDesktop, "Linux"), + (.uwp, "Windows UWP"), + (.sdk, ""), + (.server, ""), + ]) + func platform(type: DeviceType, expected: String) { + #expect(type.platform == expected) + } + + /// `displayName` combines the category and platform for types with a non-empty platform. + @Test + func displayName_withPlatform() { + #expect(DeviceType.iOS.displayName == Localizations.deviceDisplayName(Localizations.mobile, "iOS")) + } + + /// `displayName` falls back to the category's display name when the platform is empty. + @Test + func displayName_emptyPlatform() { + #expect(DeviceType.sdk.displayName == Localizations.sdk) + #expect(DeviceType.server.displayName == Localizations.server) + #expect(DeviceType.unknownBrowser.displayName == Localizations.unknown) + } + + /// `pendingRequestMatchKey` returns the extension-specific label for extension types. + @Test(arguments: [ + (DeviceType.chromeExtension, "Chrome Extension"), + (.firefoxExtension, "Firefox Extension"), + (.operaExtension, "Opera Extension"), + (.edgeExtension, "Edge Extension"), + (.safariExtension, "Safari Extension"), + (.vivaldiExtension, "Vivaldi Extension"), + ]) + func pendingRequestMatchKey_extension(type: DeviceType, expected: String) { + #expect(type.pendingRequestMatchKey == expected) + } + + /// `pendingRequestMatchKey` falls back to `platform` for non-extension types. + @Test + func pendingRequestMatchKey_default() { + #expect(DeviceType.chromeBrowser.pendingRequestMatchKey == "Chrome") + #expect(DeviceType.iOS.pendingRequestMatchKey == "iOS") + } +} diff --git a/BitwardenShared/Core/Auth/Models/Request/IdentityTokenRequestModel.swift b/BitwardenShared/Core/Auth/Models/Request/IdentityTokenRequestModel.swift index a19ca47555..7c87d7328c 100644 --- a/BitwardenShared/Core/Auth/Models/Request/IdentityTokenRequestModel.swift +++ b/BitwardenShared/Core/Auth/Models/Request/IdentityTokenRequestModel.swift @@ -58,7 +58,7 @@ extension IdentityTokenRequestModel: FormURLEncodedRequestBody { URLQueryItem(name: "deviceIdentifier", value: deviceInfo.identifier), URLQueryItem(name: "deviceName", value: deviceInfo.name), - URLQueryItem(name: "deviceType", value: String(deviceInfo.type)), + URLQueryItem(name: "deviceType", value: String(deviceInfo.type.rawValue)), ]) switch authenticationMethod { diff --git a/BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift b/BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift new file mode 100644 index 0000000000..d58939189f --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift @@ -0,0 +1,34 @@ +import BitwardenKit +import Foundation +import Networking + +// MARK: - DeviceResponse + +/// A data structure representing a device response from the API. +/// +struct DeviceResponse: JSONResponse, Equatable, Sendable, Identifiable, Hashable { + static let decoder = JSONDecoder.defaultDecoder + + // MARK: Properties + + /// The date the device was first registered. + let creationDate: Date + + /// The unique identifier of the device. + let id: String + + /// The unique identifier for this device instance. + let identifier: String + + /// Whether the device is trusted. + let isTrusted: Bool + + /// The date of the last activity on this device. + let lastActivityDate: Date? + + /// The name of the device. + let name: String? + + /// The numeric type of the device. + @DefaultValue var type: DeviceType +} diff --git a/BitwardenShared/Core/Auth/Models/Response/DeviceResponseTests.swift b/BitwardenShared/Core/Auth/Models/Response/DeviceResponseTests.swift new file mode 100644 index 0000000000..0758cdb40f --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/DeviceResponseTests.swift @@ -0,0 +1,42 @@ +import TestHelpers +import XCTest + +@testable import BitwardenShared + +// MARK: - DeviceResponseTests + +class DeviceResponseTests: BitwardenTestCase { + // MARK: Decoding + + /// Validates decoding a `DeviceResponse` from the `CurrentDevice.json` fixture. + func test_decode() throws { + let subject = try JSONDecoder.defaultDecoder.decode( + DeviceResponse.self, + from: APITestData.currentDevice.data, + ) + XCTAssertEqual(subject.id, "device-id-1") + XCTAssertEqual(subject.identifier, "device-identifier-1") + XCTAssertEqual(subject.name, "iPhone 15 Pro") + XCTAssertEqual(subject.type, .iOS) + XCTAssertTrue(subject.isTrusted) + XCTAssertNotNil(subject.lastActivityDate) + } + + /// Decoding a `DeviceResponse` defaults `type` to `.unknownBrowser` when the server sends a + /// raw value not yet recognized by the client. + func test_decode_unknownType() throws { + let json = Data(""" + { + "id": "device-id-1", + "name": "iPhone 15 Pro", + "identifier": "device-identifier-1", + "type": 999, + "creationDate": "2024-01-01T00:00:00.000Z", + "isTrusted": true, + "lastActivityDate": null + } + """.utf8) + let subject = try JSONDecoder.defaultDecoder.decode(DeviceResponse.self, from: json) + XCTAssertEqual(subject.type, .unknownBrowser) + } +} diff --git a/BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift b/BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift new file mode 100644 index 0000000000..10274b17e6 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift @@ -0,0 +1,15 @@ +import Foundation +import Networking + +// MARK: - DevicesListResponse + +/// The response returned from the API when requesting the list of devices. +/// +struct DevicesListResponse: JSONResponse { + static let decoder = JSONDecoder.defaultDecoder + + // MARK: Properties + + /// The list of devices returned by the API request. + let data: [DeviceResponse] +} diff --git a/BitwardenShared/Core/Auth/Models/Response/DevicesListResponseTests.swift b/BitwardenShared/Core/Auth/Models/Response/DevicesListResponseTests.swift new file mode 100644 index 0000000000..2cbd593dbe --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/DevicesListResponseTests.swift @@ -0,0 +1,33 @@ +import TestHelpers +import XCTest + +@testable import BitwardenShared + +// MARK: - DevicesListResponseTests + +class DevicesListResponseTests: BitwardenTestCase { + // MARK: Decoding + + /// Validates decoding a `DevicesListResponse` from the `DevicesList.json` fixture. + func test_decode() throws { + let subject = try JSONDecoder.defaultDecoder.decode( + DevicesListResponse.self, + from: APITestData.devicesList.data, + ) + XCTAssertEqual(subject.data.count, 2) + + let first = subject.data[0] + XCTAssertEqual(first.id, "device-id-1") + XCTAssertEqual(first.identifier, "device-identifier-1") + XCTAssertEqual(first.name, "iPhone 15 Pro") + XCTAssertEqual(first.type, .iOS) + XCTAssertTrue(first.isTrusted) + XCTAssertNotNil(first.lastActivityDate) + + let second = subject.data[1] + XCTAssertEqual(second.id, "device-id-2") + XCTAssertEqual(second.type, .chromeExtension) + XCTAssertFalse(second.isTrusted) + XCTAssertNil(second.lastActivityDate) + } +} diff --git a/BitwardenShared/Core/Auth/Models/Response/Fixtures/DeviceResponse+Fixtures.swift b/BitwardenShared/Core/Auth/Models/Response/Fixtures/DeviceResponse+Fixtures.swift new file mode 100644 index 0000000000..cdb54c3d7b --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/Fixtures/DeviceResponse+Fixtures.swift @@ -0,0 +1,26 @@ +import BitwardenKit +import Foundation + +@testable import BitwardenShared + +extension DeviceResponse { + static func fixture( + creationDate: Date = Date(timeIntervalSince1970: 1_704_067_200), + id: String = "device-id-1", + identifier: String = "device-identifier-1", + isTrusted: Bool = true, + lastActivityDate: Date? = Date(timeIntervalSince1970: 1_718_452_200), + name: String? = "iPhone 15 Pro", + type: DeviceType = .iOS, + ) -> DeviceResponse { + DeviceResponse( + creationDate: creationDate, + id: id, + identifier: identifier, + isTrusted: isTrusted, + lastActivityDate: lastActivityDate, + name: name, + type: type, + ) + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift index bd83542ff6..3e302d37d3 100644 --- a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift @@ -2,7 +2,20 @@ /// A protocol for an API service used to make device requests. /// -protocol DeviceAPIService { +protocol DeviceAPIService { // sourcery: AutoMockable + /// Retrieves the current device by its app identifier. + /// + /// - Parameter appId: The unique app identifier for this device. + /// - Returns: The `DeviceResponse` for the current device. + /// + func getCurrentDevice(appId: String) async throws -> DeviceResponse + + /// Retrieves the list of devices for the current user. + /// + /// - Returns: An array of `DeviceResponse` representing all devices. + /// + func getDevices() async throws -> [DeviceResponse] + /// Queries the API to determine if this device was previously associated with the email address. /// /// - Parameters: @@ -17,6 +30,15 @@ protocol DeviceAPIService { // MARK: - APIService extension APIService: DeviceAPIService { + func getCurrentDevice(appId: String) async throws -> DeviceResponse { + try await apiService.send(CurrentDeviceRequest(appId: appId)) + } + + func getDevices() async throws -> [DeviceResponse] { + let response = try await apiService.send(DevicesListRequest()) + return response.data + } + func knownDevice(email: String, deviceIdentifier: String) async throws -> Bool { let request = KnownDeviceRequest(email: email, deviceIdentifier: deviceIdentifier) let response = try await apiUnauthenticatedService.send(request) diff --git a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift index 547541017f..f75cdcbe79 100644 --- a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift +++ b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIServiceTests.swift @@ -28,6 +28,60 @@ class DeviceAPIServiceTests: BitwardenTestCase { // MARK: Tests + /// `getCurrentDevice(appId:)` returns the correct device from the API with a successful request. + func test_getCurrentDevice_success() async throws { + client.result = .httpSuccess(testData: .currentDevice) + + let device = try await subject.getCurrentDevice(appId: "app-id-123") + + let request = try XCTUnwrap(client.requests.first) + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.url.relativePath, "/api/devices/identifier/app-id-123") + XCTAssertNil(request.body) + + XCTAssertEqual(device.id, "device-id-1") + XCTAssertEqual(device.identifier, "device-identifier-1") + XCTAssertEqual(device.type, .iOS) + XCTAssertTrue(device.isTrusted) + } + + /// `getCurrentDevice(appId:)` throws an error if the request fails. + func test_getCurrentDevice_httpFailure() async { + client.result = .httpFailure() + + await assertAsyncThrows { + _ = try await subject.getCurrentDevice(appId: "app-id-123") + } + } + + /// `getDevices()` returns the correct list of devices from the API with a successful request. + func test_getDevices_success() async throws { + client.result = .httpSuccess(testData: .devicesList) + + let devices = try await subject.getDevices() + + let request = try XCTUnwrap(client.requests.first) + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.url.relativePath, "/api/devices") + XCTAssertNil(request.body) + + XCTAssertEqual(devices.count, 2) + XCTAssertEqual(devices[0].id, "device-id-1") + XCTAssertTrue(devices[0].isTrusted) + XCTAssertEqual(devices[1].id, "device-id-2") + XCTAssertFalse(devices[1].isTrusted) + XCTAssertNil(devices[1].lastActivityDate) + } + + /// `getDevices()` throws an error if the request fails. + func test_getDevices_httpFailure() async { + client.result = .httpFailure() + + await assertAsyncThrows { + _ = try await subject.getDevices() + } + } + /// `knownDevice(email:deviceIdentifier:)` returns the correct value from the API with a successful request. func test_knownDevice_success() async throws { let resultData = APITestData.knownDeviceTrue diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/APITestData+Device.swift b/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/APITestData+Device.swift index 03a57b2ddd..db27b9a89b 100644 --- a/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/APITestData+Device.swift +++ b/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/APITestData+Device.swift @@ -8,6 +8,14 @@ public extension APITestData { static let knownDeviceTrue = APITestData(data: Data("true".utf8)) static let knownDeviceFalse = APITestData(data: Data("false".utf8)) + + // MARK: Current Device + + static let currentDevice = loadFromJsonBundle(resource: "CurrentDevice") + + // MARK: Devices List + + static let devicesList = loadFromJsonBundle(resource: "DevicesList") } // swiftlint:enable missing_docs diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/CurrentDevice.json b/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/CurrentDevice.json new file mode 100644 index 0000000000..584b8718a5 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/CurrentDevice.json @@ -0,0 +1,9 @@ +{ + "id": "device-id-1", + "name": "iPhone 15 Pro", + "identifier": "device-identifier-1", + "type": 1, + "creationDate": "2024-01-01T00:00:00.000Z", + "isTrusted": true, + "lastActivityDate": "2024-06-15T10:30:00.000Z" +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/DevicesList.json b/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/DevicesList.json new file mode 100644 index 0000000000..367cf74b2c --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Fixtures/DevicesList.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "id": "device-id-1", + "name": "iPhone 15 Pro", + "identifier": "device-identifier-1", + "type": 1, + "creationDate": "2024-01-01T00:00:00.000Z", + "isTrusted": true, + "lastActivityDate": "2024-06-15T10:30:00.000Z" + }, + { + "id": "device-id-2", + "name": "Chrome Extension", + "identifier": "device-identifier-2", + "type": 2, + "creationDate": "2024-02-01T00:00:00.000Z", + "isTrusted": false, + "lastActivityDate": null + } + ] +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift b/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift new file mode 100644 index 0000000000..a69a879cce --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift @@ -0,0 +1,18 @@ +import Networking + +// MARK: - CurrentDeviceRequest + +/// A request for retrieving the current device by its app identifier. +/// +struct CurrentDeviceRequest: Request { + typealias Response = DeviceResponse + + // MARK: Properties + + /// The unique app identifier for this device. + let appId: String + + var method: HTTPMethod { .get } + + var path: String { "/devices/identifier/\(appId)" } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequestTests.swift b/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequestTests.swift new file mode 100644 index 0000000000..23d8c85899 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequestTests.swift @@ -0,0 +1,37 @@ +import Testing + +@testable import BitwardenShared + +// MARK: - CurrentDeviceRequestTests + +struct CurrentDeviceRequestTests { + // MARK: Tests + + /// `body` is `nil`. + @Test + func body() { + let subject = CurrentDeviceRequest(appId: "app-id") + #expect(subject.body == nil) + } + + /// `method` is `.get`. + @Test + func method() { + let subject = CurrentDeviceRequest(appId: "app-id") + #expect(subject.method == .get) + } + + /// `path` interpolates the `appId` into the correct URL path. + @Test + func path() { + let subject = CurrentDeviceRequest(appId: "app-id-123") + #expect(subject.path == "/devices/identifier/app-id-123") + } + + /// `query` is empty. + @Test + func query() { + let subject = CurrentDeviceRequest(appId: "app-id") + #expect(subject.query.isEmpty) + } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift b/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift new file mode 100644 index 0000000000..5beb223c91 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift @@ -0,0 +1,13 @@ +import Networking + +// MARK: - DevicesListRequest + +/// A request for retrieving the list of devices for the current user. +/// +struct DevicesListRequest: Request { + typealias Response = DevicesListResponse + + var method: HTTPMethod { .get } + + var path: String { "/devices" } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequestTests.swift b/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequestTests.swift new file mode 100644 index 0000000000..daaa29cd8a --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequestTests.swift @@ -0,0 +1,37 @@ +import Testing + +@testable import BitwardenShared + +// MARK: - DevicesListRequestTests + +struct DevicesListRequestTests { + // MARK: Tests + + /// `body` is `nil`. + @Test + func body() { + let subject = DevicesListRequest() + #expect(subject.body == nil) + } + + /// `method` is `.get`. + @Test + func method() { + let subject = DevicesListRequest() + #expect(subject.method == .get) + } + + /// `path` is the correct value. + @Test + func path() { + let subject = DevicesListRequest() + #expect(subject.path == "/devices") + } + + /// `query` is empty. + @Test + func query() { + let subject = DevicesListRequest() + #expect(subject.query.isEmpty) + } +} diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 68396e5be3..2f7609a1cd 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -90,6 +90,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// The service to get server-specified configuration public let configService: ConfigService + /// The service used by the application to make device API requests. + let deviceAPIService: DeviceAPIService + /// The service to make and use the device auth key. let deviceAuthKeyService: DeviceAuthKeyService @@ -266,6 +269,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// for mTLS authentication. /// - clientService: The service used by the application to handle encryption and decryption tasks. /// - configService: The service to get server-specified configuration. + /// - deviceAPIService: The service used by the application to make device API requests. /// - deviceAuthKeyService: The service to make and use the device auth key. /// - environmentService: The service used by the application to manage the environment settings. /// - errorReportBuilder: A helper for building an error report containing the details of an @@ -342,6 +346,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le clientCertificateService: ClientCertificateService, clientService: ClientService, configService: ConfigService, + deviceAPIService: DeviceAPIService, deviceAuthKeyService: DeviceAuthKeyService, environmentService: EnvironmentService, errorReportBuilder: ErrorReportBuilder, @@ -412,6 +417,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.clientCertificateService = clientCertificateService self.clientService = clientService self.configService = configService + self.deviceAPIService = deviceAPIService self.deviceAuthKeyService = deviceAuthKeyService self.environmentService = environmentService self.errorReportBuilder = errorReportBuilder @@ -1174,6 +1180,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le clientCertificateService: clientCertificateService, clientService: clientService, configService: configService, + deviceAPIService: apiService, deviceAuthKeyService: deviceAuthKeyService, environmentService: environmentService, errorReportBuilder: errorReportBuilder, @@ -1274,10 +1281,6 @@ extension ServiceContainer { apiService } - var deviceAPIService: DeviceAPIService { - apiService - } - var fileAPIService: FileAPIService { apiService } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift index bd5db6df85..a10fb91019 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift @@ -34,6 +34,7 @@ extension ServiceContainer { clientCertificateService: ClientCertificateService = MockClientCertificateService(), clientService: ClientService = MockClientService(), configService: ConfigService = MockConfigService(), + deviceAPIService: DeviceAPIService? = nil, deviceAuthKeyService: DeviceAuthKeyService = MockDeviceAuthKeyService(), environmentService: EnvironmentService = MockEnvironmentService(), errorReportBuilder: ErrorReportBuilder = MockErrorReportBuilder(), @@ -96,11 +97,12 @@ extension ServiceContainer { actualSearchProcessorMediatorFactory = factoryMock } + let apiService = APIService( + client: httpClient, + environmentService: environmentService, + ) return ServiceContainer( - apiService: APIService( - client: httpClient, - environmentService: environmentService, - ), + apiService: apiService, appContextHelper: appContextHelper, appIDService: AppIDService(appIDSettingsStore: appIDSettingsStore), appInfoService: appInfoService, @@ -122,6 +124,7 @@ extension ServiceContainer { clientCertificateService: clientCertificateService, clientService: clientService, configService: configService, + deviceAPIService: deviceAPIService ?? apiService, deviceAuthKeyService: deviceAuthKeyService, environmentService: environmentService, errorReportBuilder: errorReportBuilder,