mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
[BWA-85] Add debug screen (#166)
This commit is contained in:
parent
40f90b85cf
commit
83e8934bfa
@ -94,8 +94,8 @@ custom_rules:
|
||||
severity: warning
|
||||
todo_without_jira:
|
||||
name: "TODO without JIRA"
|
||||
regex: "(TODO|TO DO|FIX|FIXME|FIX ME|todo)(?!: BIT-[0-9]{1,})" # "TODO: BIT-123"
|
||||
message: "All TODOs must be followed by a JIRA reference, for example: \"TODO: BIT-123\""
|
||||
regex: "(TODO|TO DO|FIX|FIXME|FIX ME|todo)(?!: BWA-[0-9]{1,})" # "TODO: BWA-123"
|
||||
message: "All TODOs must be followed by a JIRA reference, for example: \"TODO: BWA-123\""
|
||||
match_kinds:
|
||||
- comment
|
||||
severity: warning
|
||||
|
||||
@ -5,6 +5,11 @@ import UIKit
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
// MARK: Properties
|
||||
|
||||
/// The processor that manages application level logic.
|
||||
var appProcessor: AppProcessor? {
|
||||
(UIApplication.shared.delegate as? AppDelegateType)?.appProcessor
|
||||
}
|
||||
|
||||
/// Whether the app is still starting up. This ensures the splash view isn't dismissed on start
|
||||
/// up until the processor has shown the initial view.
|
||||
var isStartingUp = true
|
||||
@ -24,7 +29,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
guard let appProcessor = (UIApplication.shared.delegate as? AppDelegateType)?.appProcessor else {
|
||||
guard let appProcessor else {
|
||||
if (UIApplication.shared.delegate as? AppDelegateType)?.isTesting == true {
|
||||
// If the app is running tests, show a testing view.
|
||||
window = buildSplashWindow(windowScene: windowScene)
|
||||
@ -34,7 +39,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
}
|
||||
|
||||
let rootViewController = RootViewController()
|
||||
let appWindow = UIWindow(windowScene: windowScene)
|
||||
let appWindow = ShakeWindow(windowScene: windowScene) { [weak self] in
|
||||
#if DEBUG_MENU
|
||||
self?.appProcessor?.showDebugMenu()
|
||||
#endif
|
||||
}
|
||||
appWindow.rootViewController = rootViewController
|
||||
appWindow.makeKeyAndVisible()
|
||||
window = appWindow
|
||||
@ -92,4 +101,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
private func showSplash() {
|
||||
splashWindow?.alpha = 1
|
||||
}
|
||||
|
||||
#if DEBUG_MENU
|
||||
/// Handle the triple-tap gesture and launch the debug menu.
|
||||
@objc
|
||||
private func handleTripleTapGesture() {
|
||||
appProcessor?.showDebugMenu()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG_MENU
|
||||
/// Add the triple-tap gesture recognizer to the window.
|
||||
private func addTripleTapGestureRecognizer(to window: UIWindow) {
|
||||
let tapGesture = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleTripleTapGesture)
|
||||
)
|
||||
tapGesture.numberOfTapsRequired = 3
|
||||
tapGesture.numberOfTouchesRequired = 1
|
||||
window.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -151,8 +151,8 @@ class DefaultKeychainRepository: KeychainRepository {
|
||||
)
|
||||
|
||||
if let resultDictionary = foundItem as? [String: Any],
|
||||
let data = resultDictionary[kSecValueData as String] as? Data {
|
||||
let string = String(decoding: data, as: UTF8.self)
|
||||
let data = resultDictionary[kSecValueData as String] as? Data,
|
||||
let string = String(data: data, encoding: .utf8) {
|
||||
guard !string.isEmpty else {
|
||||
throw KeychainServiceError.keyNotFound(item)
|
||||
}
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - ServerConfig
|
||||
|
||||
/// Model that represents the configuration provided by the server at a particular time.
|
||||
///
|
||||
struct ServerConfig: Equatable, Codable, Sendable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The environment URLs of the server.
|
||||
let environment: EnvironmentServerConfig?
|
||||
|
||||
/// The particular time of the server configuration.
|
||||
let date: Date
|
||||
|
||||
/// Feature flags to configure the client.
|
||||
let featureStates: [FeatureFlag: AnyCodable]
|
||||
|
||||
/// The git hash of the server.
|
||||
let gitHash: String
|
||||
|
||||
/// Third party server information.
|
||||
let server: ThirdPartyServerConfig?
|
||||
|
||||
/// The version of the server.
|
||||
let version: String
|
||||
|
||||
init(date: Date, responseModel: ConfigResponseModel) {
|
||||
environment = responseModel.environment.map(EnvironmentServerConfig.init)
|
||||
self.date = date
|
||||
let features: [(FeatureFlag, AnyCodable)]
|
||||
features = responseModel.featureStates.compactMap { key, value in
|
||||
guard let flag = FeatureFlag(rawValue: key) else { return nil }
|
||||
return (flag, value)
|
||||
}
|
||||
featureStates = Dictionary(uniqueKeysWithValues: features)
|
||||
|
||||
gitHash = responseModel.gitHash
|
||||
server = responseModel.server.map(ThirdPartyServerConfig.init)
|
||||
version = responseModel.version
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ThirdPartyServerConfig
|
||||
|
||||
/// Model for third-party configuration of the server.
|
||||
///
|
||||
struct ThirdPartyServerConfig: Equatable, Codable {
|
||||
/// The name of the third party configuration.
|
||||
let name: String
|
||||
|
||||
/// The URL of the third party configuration.
|
||||
let url: String
|
||||
|
||||
init(responseModel: ThirdPartyConfigResponseModel) {
|
||||
name = responseModel.name
|
||||
url = responseModel.url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EnvironmentServerConfig
|
||||
|
||||
/// Model for the environment URLs in a server configuration.
|
||||
struct EnvironmentServerConfig: Equatable, Codable {
|
||||
/// The API URL.
|
||||
let api: String?
|
||||
|
||||
/// The Cloud Region (e.g. "US")
|
||||
let cloudRegion: String?
|
||||
|
||||
/// The Identity URL.
|
||||
let identity: String?
|
||||
|
||||
/// The Notifications URL.
|
||||
let notifications: String?
|
||||
|
||||
/// The SSO URL.
|
||||
let sso: String?
|
||||
|
||||
/// The Vault URL.
|
||||
let vault: String?
|
||||
|
||||
init(responseModel: EnvironmentServerConfigResponseModel) {
|
||||
api = responseModel.api
|
||||
cloudRegion = responseModel.cloudRegion
|
||||
identity = responseModel.identity
|
||||
notifications = responseModel.notifications
|
||||
sso = responseModel.sso
|
||||
vault = responseModel.vault
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
final class ServerConfigTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// `init` properly converts feature flags
|
||||
func test_init_featureFlags() {
|
||||
let model = ConfigResponseModel(
|
||||
environment: nil,
|
||||
featureStates: [
|
||||
"vault-onboarding": .bool(true),
|
||||
"test-remote-feature-flag": .bool(false),
|
||||
"not-a-real-feature-flag": .int(42),
|
||||
],
|
||||
gitHash: "123",
|
||||
server: nil,
|
||||
version: "1.2.3"
|
||||
)
|
||||
|
||||
let subject = ServerConfig(date: Date(), responseModel: model)
|
||||
XCTAssertEqual(subject.featureStates, [.testRemoteFeatureFlag: .bool(false)])
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
/// An enum to represent a feature flag sent by the server
|
||||
///
|
||||
enum FeatureFlag: String, Codable {
|
||||
enum FeatureFlag: String, CaseIterable, Codable {
|
||||
// MARK: Feature Flags
|
||||
|
||||
/// A feature flag that determines whether or not the password manager sync capability is enabled.
|
||||
@ -12,28 +12,72 @@ enum FeatureFlag: String, Codable {
|
||||
|
||||
// MARK: Test Flags
|
||||
|
||||
/// A test feature flag that has a local boolean default.
|
||||
case testLocalBoolFlag = "test-local-bool-flag"
|
||||
/// A test feature flag that isn't remotely configured and has no initial value.
|
||||
case testLocalFeatureFlag = "test-local-feature-flag"
|
||||
|
||||
/// A test feature flag that has a local integer default.
|
||||
case testLocalIntFlag = "test-local-int-flag"
|
||||
/// A test feature flag that has an initial boolean value and is not remotely configured.
|
||||
case testLocalInitialBoolFlag = "test-local-initial-bool-flag"
|
||||
|
||||
/// A test feature flag that has a local string default.
|
||||
case testLocalStringFlag = "test-local-string-flag"
|
||||
/// A test feature flag that has an initial integer value and is not remotely configured.
|
||||
case testLocalInitialIntFlag = "test-local-initial-int-flag"
|
||||
|
||||
/// A test feature flag to represent a value that doesn't have a local default.
|
||||
case testRemoteFlag
|
||||
/// A test feature flag that has an initial string value and is not remotely configured.
|
||||
case testLocalInitialStringFlag = "test-local-initial-string-flag"
|
||||
|
||||
// MARK: Static Properties
|
||||
/// A test feature flag that can be remotely configured.
|
||||
case testRemoteFeatureFlag = "test-remote-feature-flag"
|
||||
|
||||
/// The values to start the value for each flag at locally.
|
||||
/// A test feature flag that has an initial boolean value and is not remotely configured.
|
||||
case testRemoteInitialBoolFlag = "test-remote-initial-bool-flag"
|
||||
|
||||
/// A test feature flag that has an initial integer value and is not remotely configured.
|
||||
case testRemoteInitialIntFlag = "test-remote-initial-int-flag"
|
||||
|
||||
/// A test feature flag that has an initial string value and is not remotely configured.
|
||||
case testRemoteInitialStringFlag = "test-remote-initial-string-flag"
|
||||
|
||||
// MARK: Type Properties
|
||||
|
||||
/// An array of feature flags available in the debug menu.
|
||||
static var debugMenuFeatureFlags: [FeatureFlag] {
|
||||
allCases.filter { !$0.rawValue.hasPrefix("test-") }
|
||||
}
|
||||
|
||||
/// The initial values for feature flags.
|
||||
/// If `isRemotelyConfigured` is true for the flag, then this will get overridden by the server;
|
||||
/// but if `isRemotelyConfigured` is false for the flag, then the value here will be used.
|
||||
/// This is a helpful way to manage local feature flags.
|
||||
static let initialLocalValues: [FeatureFlag: AnyCodable] = [
|
||||
static let initialValues: [FeatureFlag: AnyCodable] = [
|
||||
.enablePasswordManagerSync: .bool(false),
|
||||
.testLocalBoolFlag: .bool(true),
|
||||
.testLocalIntFlag: .int(42),
|
||||
.testLocalStringFlag: .string("Test String"),
|
||||
.testLocalInitialBoolFlag: .bool(true),
|
||||
.testLocalInitialIntFlag: .int(42),
|
||||
.testLocalInitialStringFlag: .string("Test String"),
|
||||
.testRemoteInitialBoolFlag: .bool(true),
|
||||
.testRemoteInitialIntFlag: .int(42),
|
||||
.testRemoteInitialStringFlag: .string("Test String"),
|
||||
]
|
||||
|
||||
// MARK: Instance Properties
|
||||
|
||||
/// Whether this feature can be enabled remotely.
|
||||
var isRemotelyConfigured: Bool {
|
||||
switch self {
|
||||
case .enablePasswordManagerSync,
|
||||
.testLocalFeatureFlag,
|
||||
.testLocalInitialBoolFlag,
|
||||
.testLocalInitialIntFlag,
|
||||
.testLocalInitialStringFlag:
|
||||
false
|
||||
case .testRemoteFeatureFlag,
|
||||
.testRemoteInitialBoolFlag,
|
||||
.testRemoteInitialIntFlag,
|
||||
.testRemoteInitialStringFlag:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The display name of the feature flag.
|
||||
var name: String {
|
||||
rawValue.split(separator: "-").map(\.localizedCapitalized).joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
final class FeatureFlagTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// `debugMenuFeatureFlags` does not include any test flags
|
||||
func test_debugMenu_testFlags() {
|
||||
let actual = FeatureFlag.debugMenuFeatureFlags.map(\.rawValue)
|
||||
let filtered = actual.filter { $0.hasPrefix("test-") }
|
||||
XCTAssertEqual(filtered, [])
|
||||
}
|
||||
|
||||
/// `name` formats the raw value of a feature flag
|
||||
func test_name() {
|
||||
XCTAssertEqual(FeatureFlag.testLocalFeatureFlag.name, "Test Local Feature Flag")
|
||||
XCTAssertEqual(FeatureFlag.testLocalInitialBoolFlag.name, "Test Local Initial Bool Flag")
|
||||
XCTAssertEqual(FeatureFlag.testLocalInitialIntFlag.name, "Test Local Initial Int Flag")
|
||||
XCTAssertEqual(FeatureFlag.testLocalInitialStringFlag.name, "Test Local Initial String Flag")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Networking
|
||||
|
||||
// MARK: - ConfigResponseModel
|
||||
|
||||
/// API response model for the configuration request.
|
||||
///
|
||||
struct ConfigResponseModel: Equatable, JSONResponse {
|
||||
// MARK: Properties
|
||||
|
||||
/// The environment URLs of the server.
|
||||
let environment: EnvironmentServerConfigResponseModel?
|
||||
|
||||
/// Feature flags to configure the client.
|
||||
let featureStates: [String: AnyCodable]
|
||||
|
||||
/// The git hash of the server.
|
||||
let gitHash: String
|
||||
|
||||
/// Third party server information.
|
||||
let server: ThirdPartyConfigResponseModel?
|
||||
|
||||
/// The version of the server.
|
||||
let version: String
|
||||
}
|
||||
|
||||
/// API response model for third-party configuration in a configuration response.
|
||||
struct ThirdPartyConfigResponseModel: Equatable, JSONResponse {
|
||||
/// The name of the third party configuration.
|
||||
let name: String
|
||||
|
||||
/// The URL of the third party configuration.
|
||||
let url: String
|
||||
}
|
||||
|
||||
/// API response model for the environment URLs in a configuration response.
|
||||
struct EnvironmentServerConfigResponseModel: Equatable, JSONResponse {
|
||||
/// The API URL.
|
||||
let api: String?
|
||||
|
||||
/// The Cloud Region (e.g. "US")
|
||||
let cloudRegion: String?
|
||||
|
||||
/// The Identity URL.
|
||||
let identity: String?
|
||||
|
||||
/// The Notifications URL.
|
||||
let notifications: String?
|
||||
|
||||
/// The SSO URL.
|
||||
let sso: String?
|
||||
|
||||
/// The Vault URL.
|
||||
let vault: String?
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@ -7,6 +8,19 @@ import OSLog
|
||||
/// This is significantly pared down from the `ConfigService` in the PM app.
|
||||
///
|
||||
protocol ConfigService {
|
||||
/// Retrieves the current configuration. This will return the on-disk configuration if available,
|
||||
/// or will retrieve it from the server if not. It will also retrieve the configuration from
|
||||
/// the server if it is outdated or if the `forceRefresh` argument is `true`. Configurations
|
||||
/// retrieved from the server are saved to disk.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - forceRefresh: If true, forces refreshing the configuration from the server.
|
||||
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
|
||||
/// - Returns: A server configuration if able.
|
||||
///
|
||||
@discardableResult
|
||||
func getConfig(forceRefresh: Bool, isPreAuth: Bool) async -> ServerConfig?
|
||||
|
||||
/// Retrieves a boolean feature flag. This will use the on-disk configuration if available,
|
||||
/// or will retrieve it from the server if not. It will also retrieve the configuration from
|
||||
/// the server if it is outdated or if the `forceRefresh` argument is `true`.
|
||||
@ -15,8 +29,9 @@ protocol ConfigService {
|
||||
/// - flag: The feature flag to retrieve
|
||||
/// - defaultValue: The default value to use if the flag is not in the server configuration
|
||||
/// - forceRefresh: If true, forces refreshing the configuration from the server before retrieval
|
||||
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
|
||||
/// - Returns: The value for the feature flag
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool) async -> Bool
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool, isPreAuth: Bool) async -> Bool
|
||||
|
||||
/// Retrieves an integer feature flag. This will use the on-disk configuration if available,
|
||||
/// or will retrieve it from the server if not. It will also retrieve the configuration from
|
||||
@ -26,8 +41,9 @@ protocol ConfigService {
|
||||
/// - flag: The feature flag to retrieve
|
||||
/// - defaultValue: The default value to use if the flag is not in the server configuration
|
||||
/// - forceRefresh: If true, forces refreshing the configuration from the server before retrieval
|
||||
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
|
||||
/// - Returns: The value for the feature flag
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool) async -> Int
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool, isPreAuth: Bool) async -> Int
|
||||
|
||||
/// Retrieves a string feature flag. This will use the on-disk configuration if available,
|
||||
/// or will retrieve it from the server if not. It will also retrieve the configuration from
|
||||
@ -37,21 +53,49 @@ protocol ConfigService {
|
||||
/// - flag: The feature flag to retrieve
|
||||
/// - defaultValue: The default value to use if the flag is not in the server configuration
|
||||
/// - forceRefresh: If true, forces refreshing the configuration from the server before retrieval
|
||||
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
|
||||
/// - Returns: The value for the feature flag
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String?, forceRefresh: Bool) async -> String?
|
||||
func getFeatureFlag(
|
||||
_ flag: FeatureFlag,
|
||||
defaultValue: String?,
|
||||
forceRefresh: Bool,
|
||||
isPreAuth: Bool
|
||||
) async -> String?
|
||||
|
||||
// MARK: Debug Feature Flags
|
||||
|
||||
/// Retrieves the debug menu feature flags.
|
||||
///
|
||||
func getDebugFeatureFlags() async -> [DebugMenuFeatureFlag]
|
||||
|
||||
/// Toggles the value of a debug feature flag in the app's settings store.
|
||||
///
|
||||
func toggleDebugFeatureFlag(
|
||||
name: String,
|
||||
newValue: Bool?
|
||||
) async -> [DebugMenuFeatureFlag]
|
||||
|
||||
/// Refreshes the list of debug feature flags by reloading their values from the settings store.
|
||||
///
|
||||
func refreshDebugFeatureFlags() async -> [DebugMenuFeatureFlag]
|
||||
}
|
||||
|
||||
extension ConfigService {
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool = false) async -> Bool {
|
||||
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false)
|
||||
@discardableResult
|
||||
func getConfig(isPreAuth: Bool = false) async -> ServerConfig? {
|
||||
await getConfig(forceRefresh: false, isPreAuth: isPreAuth)
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int = 0) async -> Int {
|
||||
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false)
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool = false, isPreAuth: Bool = false) async -> Bool {
|
||||
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false, isPreAuth: isPreAuth)
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String? = nil) async -> String? {
|
||||
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false)
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int = 0, isPreAuth: Bool = false) async -> Int {
|
||||
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false, isPreAuth: isPreAuth)
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String? = nil, isPreAuth: Bool = false) async -> String? {
|
||||
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false, isPreAuth: isPreAuth)
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,35 +106,141 @@ extension ConfigService {
|
||||
class DefaultConfigService: ConfigService {
|
||||
// MARK: Properties
|
||||
|
||||
/// The App Settings Store used for storing and retrieving values from User Defaults.
|
||||
private let appSettingsStore: AppSettingsStore
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
private let errorReporter: ErrorReporter
|
||||
|
||||
/// The service used by the application to manage account state.
|
||||
private let stateService: StateService
|
||||
|
||||
/// The service used to get the present time.
|
||||
private let timeProvider: TimeProvider
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultConfigService`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - appSettingsStore: The App Settings Store used for storing and retrieving values from User Defaults.
|
||||
/// - configApiService: The API service to make config requests.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - stateService: The service used by the application to manage account state.
|
||||
/// - timeProvider: The services used to get the present time.
|
||||
///
|
||||
init(
|
||||
errorReporter: ErrorReporter
|
||||
appSettingsStore: AppSettingsStore,
|
||||
errorReporter: ErrorReporter,
|
||||
stateService: StateService,
|
||||
timeProvider: TimeProvider
|
||||
) {
|
||||
self.appSettingsStore = appSettingsStore
|
||||
self.errorReporter = errorReporter
|
||||
self.stateService = stateService
|
||||
self.timeProvider = timeProvider
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool = false, forceRefresh: Bool = false) async -> Bool {
|
||||
FeatureFlag.initialLocalValues[flag]?.boolValue ?? defaultValue
|
||||
@discardableResult
|
||||
func getConfig(forceRefresh: Bool, isPreAuth: Bool) async -> ServerConfig? {
|
||||
nil
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int = 0, forceRefresh: Bool = false) async -> Int {
|
||||
FeatureFlag.initialLocalValues[flag]?.intValue ?? defaultValue
|
||||
func getFeatureFlag(
|
||||
_ flag: FeatureFlag,
|
||||
defaultValue: Bool = false,
|
||||
forceRefresh: Bool = false,
|
||||
isPreAuth: Bool = false
|
||||
) async -> Bool {
|
||||
#if DEBUG_MENU
|
||||
if let userDefaultValue = appSettingsStore.debugFeatureFlag(name: flag.rawValue) {
|
||||
return userDefaultValue
|
||||
}
|
||||
#endif
|
||||
|
||||
return FeatureFlag.initialValues[flag]?.boolValue
|
||||
?? defaultValue
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String? = nil, forceRefresh: Bool = false) async -> String? {
|
||||
FeatureFlag.initialLocalValues[flag]?.stringValue ?? defaultValue
|
||||
func getFeatureFlag(
|
||||
_ flag: FeatureFlag,
|
||||
defaultValue: Int = 0,
|
||||
forceRefresh: Bool = false,
|
||||
isPreAuth: Bool = false
|
||||
) async -> Int {
|
||||
FeatureFlag.initialValues[flag]?.intValue
|
||||
?? defaultValue
|
||||
}
|
||||
|
||||
func getFeatureFlag(
|
||||
_ flag: FeatureFlag,
|
||||
defaultValue: String? = nil,
|
||||
forceRefresh: Bool = false,
|
||||
isPreAuth: Bool = false
|
||||
) async -> String? {
|
||||
FeatureFlag.initialValues[flag]?.stringValue
|
||||
?? defaultValue
|
||||
}
|
||||
|
||||
func getDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
|
||||
let remoteFeatureFlags = await getConfig()?.featureStates ?? [:]
|
||||
|
||||
let flags = FeatureFlag.debugMenuFeatureFlags.map { feature in
|
||||
let userDefaultValue = appSettingsStore.debugFeatureFlag(name: feature.rawValue)
|
||||
let remoteFlagValue = remoteFeatureFlags[feature]?.boolValue ?? false
|
||||
|
||||
return DebugMenuFeatureFlag(
|
||||
feature: feature,
|
||||
isEnabled: userDefaultValue ?? remoteFlagValue
|
||||
)
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
func toggleDebugFeatureFlag(name: String, newValue: Bool?) async -> [DebugMenuFeatureFlag] {
|
||||
appSettingsStore.overrideDebugFeatureFlag(
|
||||
name: name,
|
||||
value: newValue
|
||||
)
|
||||
return await getDebugFeatureFlags()
|
||||
}
|
||||
|
||||
func refreshDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
|
||||
for feature in FeatureFlag.debugMenuFeatureFlags {
|
||||
appSettingsStore.overrideDebugFeatureFlag(
|
||||
name: feature.rawValue,
|
||||
value: nil
|
||||
)
|
||||
}
|
||||
return await getDebugFeatureFlags()
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
/// Gets the server config in state depending on if the call is being done before authentication.
|
||||
/// - Parameters:
|
||||
/// - config: Config to set
|
||||
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account
|
||||
func getStateServerConfig(isPreAuth: Bool) async throws -> ServerConfig? {
|
||||
guard !isPreAuth else {
|
||||
return await stateService.getPreAuthServerConfig()
|
||||
}
|
||||
return try? await stateService.getServerConfig()
|
||||
}
|
||||
|
||||
/// Sets the server config in state depending on if the call is being done before authentication.
|
||||
/// - Parameters:
|
||||
/// - config: Config to set
|
||||
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account
|
||||
/// - userId: The userId to set the server config to.
|
||||
func setStateServerConfig(_ config: ServerConfig, isPreAuth: Bool, userId: String? = nil) async throws {
|
||||
guard !isPreAuth else {
|
||||
await stateService.setPreAuthServerConfig(config: config)
|
||||
return
|
||||
}
|
||||
try? await stateService.setServerConfig(config, userId: userId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,63 +5,175 @@ import XCTest
|
||||
final class ConfigServiceTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var appSettingsStore: MockAppSettingsStore!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var now: Date!
|
||||
var stateService: MockStateService!
|
||||
var subject: DefaultConfigService!
|
||||
var timeProvider: MockTimeProvider!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
appSettingsStore = MockAppSettingsStore()
|
||||
errorReporter = MockErrorReporter()
|
||||
now = Date(year: 2024, month: 2, day: 14, hour: 8, minute: 0, second: 0)
|
||||
stateService = MockStateService()
|
||||
timeProvider = MockTimeProvider(.mockTime(now))
|
||||
subject = DefaultConfigService(
|
||||
errorReporter: errorReporter
|
||||
appSettingsStore: appSettingsStore,
|
||||
errorReporter: errorReporter,
|
||||
stateService: stateService,
|
||||
timeProvider: timeProvider
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
appSettingsStore = nil
|
||||
errorReporter = nil
|
||||
stateService = nil
|
||||
subject = nil
|
||||
timeProvider = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
// MARK: Tests - getConfig remote interactions
|
||||
|
||||
// TODO: BWA-92 to backfill these tests, or obviate it by pulling the ConfigService into a shared library.
|
||||
|
||||
// MARK: Tests - getConfig initial values
|
||||
|
||||
/// `getFeatureFlag(:)` returns the initial value for local-only booleans if it is configured.
|
||||
func test_getFeatureFlag_initialValue_localBool() async {
|
||||
let value = await subject.getFeatureFlag(
|
||||
.testLocalInitialBoolFlag,
|
||||
defaultValue: false,
|
||||
forceRefresh: false
|
||||
)
|
||||
XCTAssertTrue(value)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the initial value for local-only integers if it is configured.
|
||||
func test_getFeatureFlag_initialValue_localInt() async {
|
||||
let value = await subject.getFeatureFlag(
|
||||
.testLocalInitialIntFlag,
|
||||
defaultValue: 10,
|
||||
forceRefresh: false
|
||||
)
|
||||
XCTAssertEqual(value, 42)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the initial value for local-only strings if it is configured.
|
||||
func test_getFeatureFlag_initialValue_localString() async {
|
||||
let value = await subject.getFeatureFlag(
|
||||
.testLocalInitialStringFlag,
|
||||
defaultValue: "Default",
|
||||
forceRefresh: false
|
||||
)
|
||||
XCTAssertEqual(value, "Test String")
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the initial value for remote-configured booleans if it is configured.
|
||||
func test_getFeatureFlag_initialValue_remoteBool() async {
|
||||
let value = await subject.getFeatureFlag(
|
||||
.testRemoteInitialBoolFlag,
|
||||
defaultValue: false,
|
||||
forceRefresh: false
|
||||
)
|
||||
XCTAssertTrue(value)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the initial value for remote-configured integers if it is configured.
|
||||
func test_getFeatureFlag_initialValue_remoteInt() async {
|
||||
let value = await subject.getFeatureFlag(
|
||||
.testRemoteInitialIntFlag,
|
||||
defaultValue: 10,
|
||||
forceRefresh: false
|
||||
)
|
||||
XCTAssertEqual(value, 42)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the initial value for remote-configured integers if it is configured.
|
||||
func test_getFeatureFlag_initialValue_remoteString() async {
|
||||
let value = await subject.getFeatureFlag(
|
||||
.testRemoteInitialStringFlag,
|
||||
defaultValue: "Default",
|
||||
forceRefresh: false
|
||||
)
|
||||
XCTAssertEqual(value, "Test String")
|
||||
}
|
||||
|
||||
// MARK: Tests - getFeatureFlag
|
||||
|
||||
/// `getFeatureFlag(:)` returns the default value for booleans
|
||||
func test_getFeatureFlag_bool_fallback() async {
|
||||
let value = await subject.getFeatureFlag(.testRemoteFlag, defaultValue: false, forceRefresh: false)
|
||||
XCTAssertFalse(value)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the default local value for booleans if it is configured.
|
||||
func test_getFeatureFlag_bool_locallyConfigured() async {
|
||||
let value = await subject.getFeatureFlag(.testLocalBoolFlag, defaultValue: false, forceRefresh: false)
|
||||
stateService.serverConfig["1"] = ServerConfig(
|
||||
date: Date(year: 2024, month: 2, day: 14, hour: 7, minute: 50, second: 0),
|
||||
responseModel: ConfigResponseModel(
|
||||
environment: nil,
|
||||
featureStates: [:],
|
||||
gitHash: "75238191",
|
||||
server: nil,
|
||||
version: "2024.4.0"
|
||||
)
|
||||
)
|
||||
let value = await subject.getFeatureFlag(.testRemoteFeatureFlag, defaultValue: true, forceRefresh: false)
|
||||
XCTAssertTrue(value)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the default value for integers
|
||||
func test_getFeatureFlag_int_fallback() async {
|
||||
let value = await subject.getFeatureFlag(.testRemoteFlag, defaultValue: 10, forceRefresh: false)
|
||||
XCTAssertEqual(value, 10)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the default local value for integers if it is configured.
|
||||
func test_getFeatureFlag_int_locallyConfigured() async {
|
||||
let value = await subject.getFeatureFlag(.testLocalIntFlag, defaultValue: 10, forceRefresh: false)
|
||||
XCTAssertEqual(value, 42)
|
||||
stateService.serverConfig["1"] = ServerConfig(
|
||||
date: Date(year: 2024, month: 2, day: 14, hour: 7, minute: 50, second: 0),
|
||||
responseModel: ConfigResponseModel(
|
||||
environment: nil,
|
||||
featureStates: [:],
|
||||
gitHash: "75238191",
|
||||
server: nil,
|
||||
version: "2024.4.0"
|
||||
)
|
||||
)
|
||||
let value = await subject.getFeatureFlag(.testRemoteFeatureFlag, defaultValue: 30, forceRefresh: false)
|
||||
XCTAssertEqual(value, 30)
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the default value for strings
|
||||
func test_getFeatureFlag_string_fallback() async {
|
||||
let value = await subject.getFeatureFlag(.testRemoteFlag, defaultValue: "Default", forceRefresh: false)
|
||||
XCTAssertEqual(value, "Default")
|
||||
stateService.serverConfig["1"] = ServerConfig(
|
||||
date: Date(year: 2024, month: 2, day: 14, hour: 7, minute: 50, second: 0),
|
||||
responseModel: ConfigResponseModel(
|
||||
environment: nil,
|
||||
featureStates: [:],
|
||||
gitHash: "75238191",
|
||||
server: nil,
|
||||
version: "2024.4.0"
|
||||
)
|
||||
)
|
||||
let value = await subject.getFeatureFlag(.testRemoteFeatureFlag, defaultValue: "fallback", forceRefresh: false)
|
||||
XCTAssertEqual(value, "fallback")
|
||||
}
|
||||
|
||||
/// `getFeatureFlag(:)` returns the default local value for integers if it is configured.
|
||||
func test_getFeatureFlag_string_locallyConfigured() async {
|
||||
let value = await subject.getFeatureFlag(.testLocalStringFlag, defaultValue: "Default", forceRefresh: false)
|
||||
XCTAssertEqual(value, "Test String")
|
||||
// MARK: Tests - Other
|
||||
|
||||
/// `toggleDebugFeatureFlag` will correctly change the value of the flag given.
|
||||
func test_toggleDebugFeatureFlag() async throws {
|
||||
let flags = await subject.toggleDebugFeatureFlag(
|
||||
name: FeatureFlag.enablePasswordManagerSync.rawValue,
|
||||
newValue: true
|
||||
)
|
||||
XCTAssertTrue(appSettingsStore.overrideDebugFeatureFlagCalled)
|
||||
let flag = try XCTUnwrap(flags.first { $0.feature == .enablePasswordManagerSync })
|
||||
XCTAssertTrue(flag.isEnabled)
|
||||
}
|
||||
|
||||
/// `refreshDebugFeatureFlags` will reset the flags to the original state before overriding.
|
||||
func test_refreshDebugFeatureFlags() async throws {
|
||||
let flags = await subject.refreshDebugFeatureFlags()
|
||||
XCTAssertTrue(appSettingsStore.overrideDebugFeatureFlagCalled)
|
||||
let flag = try XCTUnwrap(flags.first { $0.feature == .enablePasswordManagerSync })
|
||||
XCTAssertFalse(flag.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,7 +169,10 @@ public class ServiceContainer: Services {
|
||||
)
|
||||
|
||||
let configService = DefaultConfigService(
|
||||
errorReporter: errorReporter
|
||||
appSettingsStore: appSettingsStore,
|
||||
errorReporter: errorReporter,
|
||||
stateService: stateService,
|
||||
timeProvider: timeProvider
|
||||
)
|
||||
|
||||
let cryptographyKeyService = CryptographyKeyService(
|
||||
|
||||
@ -47,12 +47,23 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue
|
||||
|
||||
/// Gets the server config used by the app prior to the user authenticating.
|
||||
/// - Returns: The server config used prior to user authentication.
|
||||
func getPreAuthServerConfig() async -> ServerConfig?
|
||||
|
||||
/// Gets the user's encryption secret key.
|
||||
///
|
||||
/// - Returns: The user's encryption secret key.
|
||||
///
|
||||
func getSecretKey(userId: String?) async throws -> String?
|
||||
|
||||
/// Gets the server config for a user ID, as set by the server.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the server config. Defaults to the active account if `nil`.
|
||||
/// - Returns: The user's server config.
|
||||
///
|
||||
func getServerConfig(userId: String?) async throws -> ServerConfig?
|
||||
|
||||
/// Get whether to show website icons.
|
||||
///
|
||||
/// - Returns: Whether to show the website icons.
|
||||
@ -87,6 +98,10 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws
|
||||
|
||||
/// Sets the server config used prior to user authentication
|
||||
/// - Parameter config: The server config to use prior to user authentication.
|
||||
func setPreAuthServerConfig(config: ServerConfig) async
|
||||
|
||||
/// Sets the user's encryption secreet key.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -94,6 +109,14 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func setSecretKey(_ key: String, userId: String?) async throws
|
||||
|
||||
/// Sets the server configuration as provided by a server for a user ID.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - configModel: The config values to set as provided by the server.
|
||||
/// - userId: The user ID associated with the server config.
|
||||
///
|
||||
func setServerConfig(_ config: ServerConfig?, userId: String?) async throws
|
||||
|
||||
/// Set whether to show the website icons.
|
||||
///
|
||||
/// - Parameter showWebIcons: Whether to show the website icons.
|
||||
@ -199,11 +222,20 @@ actor DefaultStateService: StateService {
|
||||
return appSettingsStore.clearClipboardValue(userId: userId)
|
||||
}
|
||||
|
||||
func getPreAuthServerConfig() async -> ServerConfig? {
|
||||
appSettingsStore.preAuthServerConfig
|
||||
}
|
||||
|
||||
func getSecretKey(userId: String?) async throws -> String? {
|
||||
let userId = try userId ?? getActiveAccountUserId()
|
||||
return appSettingsStore.secretKey(userId: userId)
|
||||
}
|
||||
|
||||
func getServerConfig(userId: String?) async throws -> ServerConfig? {
|
||||
let userId = try userId ?? getActiveAccountUserId()
|
||||
return appSettingsStore.serverConfig(userId: userId)
|
||||
}
|
||||
|
||||
func getShowWebIcons() async -> Bool {
|
||||
!appSettingsStore.disableWebIcons
|
||||
}
|
||||
@ -218,11 +250,20 @@ actor DefaultStateService: StateService {
|
||||
appSettingsStore.setClearClipboardValue(clearClipboardValue, userId: userId)
|
||||
}
|
||||
|
||||
func setPreAuthServerConfig(config: ServerConfig) async {
|
||||
appSettingsStore.preAuthServerConfig = config
|
||||
}
|
||||
|
||||
func setSecretKey(_ key: String, userId: String?) async throws {
|
||||
let userId = try userId ?? getActiveAccountUserId()
|
||||
appSettingsStore.setSecretKey(key, userId: userId)
|
||||
}
|
||||
|
||||
func setServerConfig(_ config: ServerConfig?, userId: String?) async throws {
|
||||
let userId = try userId ?? getActiveAccountUserId()
|
||||
appSettingsStore.setServerConfig(config, userId: userId)
|
||||
}
|
||||
|
||||
func setShowWebIcons(_ showWebIcons: Bool) async {
|
||||
appSettingsStore.disableWebIcons = !showWebIcons
|
||||
showWebIconsSubject.send(showWebIcons)
|
||||
@ -249,6 +290,16 @@ actor DefaultStateService: StateService {
|
||||
}
|
||||
}
|
||||
|
||||
extension StateService {
|
||||
/// Gets the server config for the active account.
|
||||
///
|
||||
/// - Returns: The server config sent by the server for the active account.
|
||||
///
|
||||
func getServerConfig() async throws -> ServerConfig? {
|
||||
try await getServerConfig(userId: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Biometrics
|
||||
|
||||
extension DefaultStateService {
|
||||
|
||||
@ -4,6 +4,8 @@ import OSLog
|
||||
|
||||
// MARK: - AppSettingsStore
|
||||
|
||||
// swiftlint:disable file_length
|
||||
|
||||
/// A protocol for an object that persists app setting values.
|
||||
///
|
||||
protocol AppSettingsStore: AnyObject {
|
||||
@ -28,6 +30,9 @@ protocol AppSettingsStore: AnyObject {
|
||||
/// The app's last data migration version.
|
||||
var migrationVersion: Int { get set }
|
||||
|
||||
/// The server config used prior to user authentication.
|
||||
var preAuthServerConfig: ServerConfig? { get set }
|
||||
|
||||
/// The system biometric integrity state `Data`, base64 encoded.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the Biometric Integrity State.
|
||||
@ -52,6 +57,18 @@ protocol AppSettingsStore: AnyObject {
|
||||
///
|
||||
func clearClipboardValue(userId: String) -> ClearClipboardValue
|
||||
|
||||
/// Retrieves a feature flag value from the app's settings store.
|
||||
///
|
||||
/// This method fetches the value for a specified feature flag from the app's settings store.
|
||||
/// The value is returned as a `Bool`. If the flag does not exist or cannot be decoded,
|
||||
/// the method returns `nil`.
|
||||
///
|
||||
/// - Parameter name: The name of the feature flag to retrieve, represented as a `String`.
|
||||
/// - Returns: The value of the feature flag as a `Bool`, or `nil` if the flag does not exist
|
||||
/// or cannot be decoded.
|
||||
///
|
||||
func debugFeatureFlag(name: String) -> Bool?
|
||||
|
||||
/// Flag to identify if the user has previously synced with the named account. `true` if they have previously
|
||||
/// synced with the named account, `false` if they have not synced previously.
|
||||
///
|
||||
@ -73,6 +90,19 @@ protocol AppSettingsStore: AnyObject {
|
||||
///
|
||||
func isBiometricAuthenticationEnabled(userId: String) -> Bool
|
||||
|
||||
/// Sets a feature flag value in the app's settings store.
|
||||
///
|
||||
/// This method updates or removes the value for a specified feature flag in the app's settings store.
|
||||
/// If the `value` parameter is `nil`, the feature flag is removed from the store. Otherwise, the flag
|
||||
/// is set to the provided boolean value.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: The name of the feature flag to set or remove, represented as a `String`.
|
||||
/// - value: The boolean value to assign to the feature flag. If `nil`, the feature flag will be removed
|
||||
/// from the settings store.
|
||||
///
|
||||
func overrideDebugFeatureFlag(name: String, value: Bool?)
|
||||
|
||||
/// Gets the user's secret encryption key.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -80,6 +110,12 @@ protocol AppSettingsStore: AnyObject {
|
||||
///
|
||||
func secretKey(userId: String) -> String?
|
||||
|
||||
/// The server configuration.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the server config.
|
||||
/// - Returns: The server config for that user ID.
|
||||
func serverConfig(userId: String) -> ServerConfig?
|
||||
|
||||
/// Sets the user's Biometric Authentication Preference.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -128,6 +164,14 @@ protocol AppSettingsStore: AnyObject {
|
||||
/// - userId: The user ID
|
||||
///
|
||||
func setSecretKey(_ key: String, userId: String)
|
||||
|
||||
/// Sets the server config.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - config: The server config for the user
|
||||
/// - userId: The user ID.
|
||||
///
|
||||
func setServerConfig(_ config: ServerConfig?, userId: String)
|
||||
}
|
||||
|
||||
// MARK: - DefaultAppSettingsStore
|
||||
@ -251,11 +295,14 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
case biometricIntegrityState(userId: String, bundleId: String)
|
||||
case cardClosedState(card: ItemListCard)
|
||||
case clearClipboardValue(userId: String)
|
||||
case debugFeatureFlag(name: String)
|
||||
case disableWebIcons
|
||||
case hasSeenWelcomeTutorial
|
||||
case hasSyncedAccount(name: String)
|
||||
case migrationVersion
|
||||
case preAuthServerConfig
|
||||
case secretKey(userId: String)
|
||||
case serverConfig(userId: String)
|
||||
|
||||
/// Returns the key used to store the data under for retrieving it later.
|
||||
var storageKey: String {
|
||||
@ -275,6 +322,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
key = "cardClosedState_\(card)"
|
||||
case let .clearClipboardValue(userId):
|
||||
key = "clearClipboard_\(userId)"
|
||||
case let .debugFeatureFlag(name):
|
||||
key = "debugFeatureFlag_\(name)"
|
||||
case .disableWebIcons:
|
||||
key = "disableFavicon"
|
||||
case .hasSeenWelcomeTutorial:
|
||||
@ -283,8 +332,12 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
key = "hasSyncedAccount_\(name)"
|
||||
case .migrationVersion:
|
||||
key = "migrationVersion"
|
||||
case .preAuthServerConfig:
|
||||
key = "preAuthServerConfig"
|
||||
case let .secretKey(userId):
|
||||
key = "secretKey_\(userId)"
|
||||
case let .serverConfig(userId):
|
||||
key = "serverConfig_\(userId)"
|
||||
}
|
||||
return "bwaPreferencesStorage:\(key)"
|
||||
}
|
||||
@ -320,6 +373,11 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
set { store(newValue, for: .migrationVersion) }
|
||||
}
|
||||
|
||||
var preAuthServerConfig: ServerConfig? {
|
||||
get { fetch(for: .preAuthServerConfig) }
|
||||
set { store(newValue, for: .preAuthServerConfig) }
|
||||
}
|
||||
|
||||
func biometricIntegrityState(userId: String) -> String? {
|
||||
fetch(
|
||||
for: .biometricIntegrityState(
|
||||
@ -341,6 +399,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
return .never
|
||||
}
|
||||
|
||||
func debugFeatureFlag(name: String) -> Bool? {
|
||||
fetch(for: .debugFeatureFlag(name: name))
|
||||
}
|
||||
|
||||
func hasSyncedAccount(name: String) -> Bool {
|
||||
fetch(for: .hasSyncedAccount(name: name.hexSHA256Hash))
|
||||
}
|
||||
@ -349,10 +411,18 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
fetch(for: .biometricAuthEnabled(userId: userId))
|
||||
}
|
||||
|
||||
func overrideDebugFeatureFlag(name: String, value: Bool?) {
|
||||
store(value, for: .debugFeatureFlag(name: name))
|
||||
}
|
||||
|
||||
func secretKey(userId: String) -> String? {
|
||||
fetch(for: .secretKey(userId: userId))
|
||||
}
|
||||
|
||||
func serverConfig(userId: String) -> ServerConfig? {
|
||||
fetch(for: .serverConfig(userId: userId))
|
||||
}
|
||||
|
||||
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?, for userId: String) {
|
||||
store(isEnabled, for: .biometricAuthEnabled(userId: userId))
|
||||
}
|
||||
@ -382,6 +452,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
func setSecretKey(_ key: String, userId: String) {
|
||||
store(key, for: .secretKey(userId: userId))
|
||||
}
|
||||
|
||||
func setServerConfig(_ config: ServerConfig?, userId: String) {
|
||||
store(config, for: .serverConfig(userId: userId))
|
||||
}
|
||||
}
|
||||
|
||||
/// An enumeration of possible item list cards.
|
||||
|
||||
@ -14,6 +14,8 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
var lastUserShouldConnectToWatch = false
|
||||
var localUserId: String = "localtest"
|
||||
var migrationVersion = 0
|
||||
var overrideDebugFeatureFlagCalled = false
|
||||
var preAuthServerConfig: ServerConfig?
|
||||
var rememberedEmail: String?
|
||||
var rememberedOrgIdentifier: String?
|
||||
|
||||
@ -26,6 +28,7 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
var disableAutoTotpCopyByUserId = [String: Bool]()
|
||||
var encryptedPrivateKeys = [String: String]()
|
||||
var encryptedUserKeys = [String: String]()
|
||||
var featureFlags = [String: Bool]()
|
||||
var hasSyncedAccountValues = [String: Bool]()
|
||||
var lastActiveTime = [String: Date]()
|
||||
var lastSyncTimeByUserId = [String: Date]()
|
||||
@ -34,6 +37,7 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
var pinKeyEncryptedUserKey = [String: String]()
|
||||
var pinProtectedUserKey = [String: String]()
|
||||
var secretKeys = [String: String]()
|
||||
var serverConfig = [String: ServerConfig]()
|
||||
var timeoutAction = [String: Int]()
|
||||
var twoFactorTokens = [String: String]()
|
||||
var vaultTimeout = [String: Int?]()
|
||||
@ -52,8 +56,13 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
clearClipboardValues[userId] ?? .never
|
||||
}
|
||||
|
||||
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
|
||||
clearClipboardValues[userId] = clearClipboardValue
|
||||
func debugFeatureFlag(name: String) -> Bool? {
|
||||
featureFlags[name]
|
||||
}
|
||||
|
||||
func overrideDebugFeatureFlag(name: String, value: Bool?) {
|
||||
overrideDebugFeatureFlagCalled = true
|
||||
featureFlags[name] = value
|
||||
}
|
||||
|
||||
func hasSyncedAccount(name: String) -> Bool {
|
||||
@ -68,9 +77,21 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
secretKeys[userId]
|
||||
}
|
||||
|
||||
func serverConfig(userId: String) -> ServerConfig? {
|
||||
serverConfig[userId]
|
||||
}
|
||||
|
||||
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
|
||||
clearClipboardValues[userId] = clearClipboardValue
|
||||
}
|
||||
|
||||
func setSecretKey(_ key: String, userId: String) {
|
||||
secretKeys[userId] = key
|
||||
}
|
||||
|
||||
func setServerConfig(_ config: ServerConfig?, userId: String) {
|
||||
serverConfig[userId] = config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Biometrics
|
||||
|
||||
@ -1,25 +1,61 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
@MainActor
|
||||
class MockConfigService: ConfigService {
|
||||
// MARK: Properties
|
||||
|
||||
var configMocker = InvocationMockerWithThrowingResult<(forceRefresh: Bool, isPreAuth: Bool), ServerConfig?>()
|
||||
var debugFeatureFlags = [DebugMenuFeatureFlag]()
|
||||
var featureFlagsBool = [FeatureFlag: Bool]()
|
||||
var featureFlagsInt = [FeatureFlag: Int]()
|
||||
var featureFlagsString = [FeatureFlag: String]()
|
||||
var getDebugFeatureFlagsCalled = false
|
||||
var refreshDebugFeatureFlagsCalled = false
|
||||
var toggleDebugFeatureFlagCalled = false
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool) async -> Bool {
|
||||
func getConfig(forceRefresh: Bool, isPreAuth: Bool) async -> ServerConfig? {
|
||||
try? configMocker.invoke(param: (forceRefresh: forceRefresh, isPreAuth: isPreAuth))
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool, isPreAuth: Bool) async -> Bool {
|
||||
featureFlagsBool[flag] ?? defaultValue
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool) async -> Int {
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool, isPreAuth: Bool) async -> Int {
|
||||
featureFlagsInt[flag] ?? defaultValue
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String?, forceRefresh: Bool) async -> String? {
|
||||
func getFeatureFlag(
|
||||
_ flag: FeatureFlag,
|
||||
defaultValue: String?,
|
||||
forceRefresh: Bool,
|
||||
isPreAuth: Bool
|
||||
) async -> String? {
|
||||
featureFlagsString[flag] ?? defaultValue
|
||||
}
|
||||
|
||||
func getDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
|
||||
getDebugFeatureFlagsCalled = true
|
||||
return debugFeatureFlags
|
||||
}
|
||||
|
||||
func refreshDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
|
||||
refreshDebugFeatureFlagsCalled = true
|
||||
return debugFeatureFlags
|
||||
}
|
||||
|
||||
func toggleDebugFeatureFlag(
|
||||
name: String,
|
||||
newValue: Bool?
|
||||
) async -> [DebugMenuFeatureFlag] {
|
||||
toggleDebugFeatureFlagCalled = true
|
||||
return debugFeatureFlags
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,9 @@ class MockStateService: StateService {
|
||||
var getBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
|
||||
var getBiometricIntegrityStateError: Error?
|
||||
var getSecretKeyResult: Result<String, Error> = .success("qwerty")
|
||||
var preAuthServerConfig: ServerConfig?
|
||||
var secretKeyValues = [String: String]()
|
||||
var serverConfig = [String: ServerConfig]()
|
||||
var setBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
|
||||
var setBiometricIntegrityStateError: Error?
|
||||
var setSecretKeyResult: Result<Void, Error> = .success(())
|
||||
@ -39,6 +41,15 @@ class MockStateService: StateService {
|
||||
return clearClipboardValues[userId] ?? .never
|
||||
}
|
||||
|
||||
func getPreAuthServerConfig() async -> ServerConfig? {
|
||||
preAuthServerConfig
|
||||
}
|
||||
|
||||
func getServerConfig(userId: String?) async throws -> ServerConfig? {
|
||||
let userId = try unwrapUserId(userId)
|
||||
return serverConfig[userId]
|
||||
}
|
||||
|
||||
func getShowWebIcons() async -> Bool {
|
||||
showWebIcons
|
||||
}
|
||||
@ -65,11 +76,20 @@ class MockStateService: StateService {
|
||||
try getSecretKeyResult.get()
|
||||
}
|
||||
|
||||
func setPreAuthServerConfig(config: ServerConfig) async {
|
||||
preAuthServerConfig = config
|
||||
}
|
||||
|
||||
func setSecretKey(_ key: String, userId: String?) async throws {
|
||||
try setSecretKeyResult.get()
|
||||
secretKeyValues[userId ?? "localtest"] = key
|
||||
}
|
||||
|
||||
func setServerConfig(_ config: ServerConfig?, userId: String?) async throws {
|
||||
let userId = try unwrapUserId(userId)
|
||||
serverConfig[userId] = config
|
||||
}
|
||||
|
||||
func showWebIconsPublisher() async -> AnyPublisher<Bool, Never> {
|
||||
showWebIconsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
10
AuthenticatorShared/UI/DebugMenu/DebugMenuAction.swift
Normal file
10
AuthenticatorShared/UI/DebugMenu/DebugMenuAction.swift
Normal file
@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuAction
|
||||
|
||||
/// Actions that can be processed by a `DebugMenuProcessor`.
|
||||
///
|
||||
enum DebugMenuAction: Equatable {
|
||||
/// The dismiss button was tapped.
|
||||
case dismissTapped
|
||||
}
|
||||
67
AuthenticatorShared/UI/DebugMenu/DebugMenuCoordinator.swift
Normal file
67
AuthenticatorShared/UI/DebugMenu/DebugMenuCoordinator.swift
Normal file
@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
|
||||
/// A coordinator that manages navigation for the debug menu.
|
||||
///
|
||||
final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasAppSettingsStore
|
||||
& HasConfigService
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
/// The services used by this coordinator.
|
||||
private let services: Services
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The stack navigator that is managed by this coordinator.
|
||||
private(set) weak var stackNavigator: StackNavigator?
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `DebugMenuCoordinator`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - services: The services used by this coordinator.
|
||||
/// - stackNavigator: The stack navigator that is managed by this coordinator.
|
||||
///
|
||||
init(
|
||||
services: Services,
|
||||
stackNavigator: StackNavigator
|
||||
) {
|
||||
self.services = services
|
||||
self.stackNavigator = stackNavigator
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func navigate(
|
||||
to route: DebugMenuRoute,
|
||||
context: AnyObject?
|
||||
) {
|
||||
switch route {
|
||||
case .dismiss:
|
||||
stackNavigator?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the process of displaying the debug menu.
|
||||
func start() {
|
||||
showDebugMenu()
|
||||
}
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Configures and displays the debug menu.
|
||||
private func showDebugMenu() {
|
||||
let processor = DebugMenuProcessor(
|
||||
coordinator: asAnyCoordinator(),
|
||||
services: services,
|
||||
state: DebugMenuState()
|
||||
)
|
||||
|
||||
let view = DebugMenuView(store: Store(processor: processor))
|
||||
stackNavigator?.replace(view)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class DebugMenuCoordinatorTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var appSettingsStore: MockAppSettingsStore!
|
||||
var configService: MockConfigService!
|
||||
var stackNavigator: MockStackNavigator!
|
||||
var subject: DebugMenuCoordinator!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
appSettingsStore = MockAppSettingsStore()
|
||||
configService = MockConfigService()
|
||||
stackNavigator = MockStackNavigator()
|
||||
|
||||
subject = DebugMenuCoordinator(
|
||||
services: ServiceContainer.withMocks(
|
||||
appSettingsStore: appSettingsStore,
|
||||
configService: configService
|
||||
),
|
||||
stackNavigator: stackNavigator
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
appSettingsStore = nil
|
||||
configService = nil
|
||||
stackNavigator = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `navigate(to:)` with `.dismiss` dismisses the view.
|
||||
@MainActor
|
||||
func test_navigate_dismiss() throws {
|
||||
subject.navigate(to: .dismiss)
|
||||
|
||||
let action = try XCTUnwrap(stackNavigator.actions.last)
|
||||
XCTAssertEqual(action.type, .dismissed)
|
||||
}
|
||||
|
||||
/// `start()` correctly shows the `DebugMenuView`.
|
||||
@MainActor
|
||||
func test_start() {
|
||||
subject.start()
|
||||
|
||||
XCTAssertTrue(stackNavigator.actions.last?.view is DebugMenuView)
|
||||
}
|
||||
}
|
||||
20
AuthenticatorShared/UI/DebugMenu/DebugMenuEffect.swift
Normal file
20
AuthenticatorShared/UI/DebugMenu/DebugMenuEffect.swift
Normal file
@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuEffect
|
||||
|
||||
/// Effects that can be processed by a `DebugMenuProcessor`.
|
||||
///
|
||||
enum DebugMenuEffect: Equatable {
|
||||
/// Triggers a refresh of feature flags, clearing local settings and re-fetching from the remote source.
|
||||
case refreshFeatureFlags
|
||||
|
||||
/// Toggles a specific feature flag's state.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - String: The identifier for the feature flag.
|
||||
/// - Bool: The state to which the feature flag should be set (enabled or disabled).
|
||||
case toggleFeatureFlag(String, Bool)
|
||||
|
||||
/// The view appeared and is ready to load data.
|
||||
case viewAppeared
|
||||
}
|
||||
19
AuthenticatorShared/UI/DebugMenu/DebugMenuFeatureFlag.swift
Normal file
19
AuthenticatorShared/UI/DebugMenu/DebugMenuFeatureFlag.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuFeatureFlag
|
||||
|
||||
/// A structure representing a feature flag in the debug menu, including its enabled state.
|
||||
/// This is used to display and manage feature flags within the debug menu interface.
|
||||
///
|
||||
struct DebugMenuFeatureFlag: Equatable, Identifiable {
|
||||
/// A unique identifier for the feature flag, based on its raw value.
|
||||
var id: String {
|
||||
feature.rawValue
|
||||
}
|
||||
|
||||
/// The feature flag enum that this instance represents.
|
||||
let feature: FeatureFlag
|
||||
|
||||
/// A boolean value indicating whether the feature is enabled or not.
|
||||
let isEnabled: Bool
|
||||
}
|
||||
29
AuthenticatorShared/UI/DebugMenu/DebugMenuModule.swift
Normal file
29
AuthenticatorShared/UI/DebugMenu/DebugMenuModule.swift
Normal file
@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuModule
|
||||
|
||||
/// An object that builds coordinator for the debug menu.
|
||||
@MainActor
|
||||
protocol DebugMenuModule {
|
||||
/// Initializes a coordinator for navigating between `DebugMenuRoute`s.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
|
||||
/// - Returns: A coordinator that can navigate to `DebugMenuRoute`s.
|
||||
///
|
||||
func makeDebugMenuCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<DebugMenuRoute, Void>
|
||||
}
|
||||
|
||||
extension DefaultAppModule: DebugMenuModule {
|
||||
func makeDebugMenuCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<DebugMenuRoute, Void> {
|
||||
DebugMenuCoordinator(
|
||||
services: services,
|
||||
stackNavigator: stackNavigator
|
||||
)
|
||||
.asAnyCoordinator()
|
||||
}
|
||||
}
|
||||
73
AuthenticatorShared/UI/DebugMenu/DebugMenuProcessor.swift
Normal file
73
AuthenticatorShared/UI/DebugMenu/DebugMenuProcessor.swift
Normal file
@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuProcessor
|
||||
|
||||
/// The processor used to manage state and handle actions for the `DebugMenuView`.
|
||||
///
|
||||
final class DebugMenuProcessor: StateProcessor<DebugMenuState, DebugMenuAction, DebugMenuEffect> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasConfigService
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Coordinator` that handles navigation.
|
||||
private let coordinator: AnyCoordinator<DebugMenuRoute, Void>
|
||||
|
||||
/// The services used by the processor.
|
||||
private let services: Services
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initializes a `DebugMenuProcessor`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The coordinator used for navigation.
|
||||
/// - services: The services used by the processor.
|
||||
/// - state: The state of the debug menu.
|
||||
///
|
||||
init(
|
||||
coordinator: AnyCoordinator<DebugMenuRoute, Void>,
|
||||
services: Services,
|
||||
state: DebugMenuState
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
self.services = services
|
||||
super.init(state: state)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
override func receive(_ action: DebugMenuAction) {
|
||||
switch action {
|
||||
case .dismissTapped:
|
||||
coordinator.navigate(to: .dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
override func perform(_ effect: DebugMenuEffect) async {
|
||||
switch effect {
|
||||
case .viewAppeared:
|
||||
await fetchFlags()
|
||||
case .refreshFeatureFlags:
|
||||
await refreshFlags()
|
||||
case let .toggleFeatureFlag(flag, newValue):
|
||||
state.featureFlags = await services.configService.toggleDebugFeatureFlag(
|
||||
name: flag,
|
||||
newValue: newValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private Functions
|
||||
|
||||
/// Fetch the current debug feature flags.
|
||||
private func fetchFlags() async {
|
||||
state.featureFlags = await services.configService.getDebugFeatureFlags()
|
||||
}
|
||||
|
||||
/// Refreshes the feature flags by resetting their local values and fetching the latest configurations.
|
||||
private func refreshFlags() async {
|
||||
state.featureFlags = await services.configService.refreshDebugFeatureFlags()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class DebugMenuProcessorTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var configService: MockConfigService!
|
||||
var coordinator: MockCoordinator<DebugMenuRoute, Void>!
|
||||
var subject: DebugMenuProcessor!
|
||||
|
||||
// MARK: Set Up & Tear Down
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
configService = MockConfigService()
|
||||
coordinator = MockCoordinator<DebugMenuRoute, Void>()
|
||||
subject = DebugMenuProcessor(
|
||||
coordinator: coordinator.asAnyCoordinator(),
|
||||
services: ServiceContainer.withMocks(
|
||||
configService: configService
|
||||
),
|
||||
state: DebugMenuState(featureFlags: [])
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
configService = nil
|
||||
coordinator = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `receive()` with `.dismissTapped` navigates to the `.dismiss` route.
|
||||
@MainActor
|
||||
func test_receive_dismissTapped() {
|
||||
subject.receive(.dismissTapped)
|
||||
XCTAssertEqual(coordinator.routes.last, .dismiss)
|
||||
}
|
||||
|
||||
/// `perform(.viewAppeared)` loads the correct feature flags.
|
||||
@MainActor
|
||||
func test_perform_appeared_loadsFeatureFlags() async {
|
||||
XCTAssertTrue(subject.state.featureFlags.isEmpty)
|
||||
|
||||
let flag = DebugMenuFeatureFlag(
|
||||
feature: .testLocalFeatureFlag,
|
||||
isEnabled: false
|
||||
)
|
||||
|
||||
configService.debugFeatureFlags = [flag]
|
||||
|
||||
await subject.perform(.viewAppeared)
|
||||
|
||||
XCTAssertTrue(subject.state.featureFlags.contains(flag))
|
||||
}
|
||||
|
||||
/// `perform(.refreshFeatureFlags)` refreshs the current feature flags.
|
||||
@MainActor
|
||||
func test_perform_refreshFeatureFlags() async {
|
||||
await subject.perform(.refreshFeatureFlags)
|
||||
XCTAssertTrue(configService.refreshDebugFeatureFlagsCalled)
|
||||
}
|
||||
|
||||
/// `perform(.toggleFeatureFlag)` changes the state of the feature flag.
|
||||
@MainActor
|
||||
func test_perform_toggleFeatureFlag() async {
|
||||
let flag = DebugMenuFeatureFlag(
|
||||
feature: .testLocalFeatureFlag,
|
||||
isEnabled: true
|
||||
)
|
||||
|
||||
await subject.perform(
|
||||
.toggleFeatureFlag(
|
||||
flag.feature.rawValue,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertTrue(configService.toggleDebugFeatureFlagCalled)
|
||||
}
|
||||
}
|
||||
9
AuthenticatorShared/UI/DebugMenu/DebugMenuRoute.swift
Normal file
9
AuthenticatorShared/UI/DebugMenu/DebugMenuRoute.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuRoute
|
||||
|
||||
/// A route to specific screens in the` DebugMenuView`
|
||||
public enum DebugMenuRoute: Equatable, Hashable {
|
||||
/// A route to dismiss the screen currently presented modally.
|
||||
case dismiss
|
||||
}
|
||||
10
AuthenticatorShared/UI/DebugMenu/DebugMenuState.swift
Normal file
10
AuthenticatorShared/UI/DebugMenu/DebugMenuState.swift
Normal file
@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - DebugMenuState
|
||||
|
||||
/// The state used to present the `DebugMenuView`.
|
||||
///
|
||||
struct DebugMenuState: Equatable, Sendable {
|
||||
/// The current feature flags supported.
|
||||
var featureFlags: [DebugMenuFeatureFlag] = []
|
||||
}
|
||||
87
AuthenticatorShared/UI/DebugMenu/DebugMenuView.swift
Normal file
87
AuthenticatorShared/UI/DebugMenu/DebugMenuView.swift
Normal file
@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - DebugMenuView
|
||||
|
||||
/// Represents the debug menu for configuring app settings and feature flags.
|
||||
///
|
||||
struct DebugMenuView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// The store used to render the view.
|
||||
@ObservedObject var store: Store<DebugMenuState, DebugMenuAction, DebugMenuEffect>
|
||||
|
||||
// MARK: View
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
featureFlags
|
||||
} header: {
|
||||
featureFlagSectionHeader
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
store.send(.dismissTapped)
|
||||
} label: {
|
||||
Text(Localizations.close)
|
||||
}
|
||||
.accessibilityIdentifier("close-debug")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Debug Menu")
|
||||
.task {
|
||||
await store.perform(.viewAppeared)
|
||||
}
|
||||
}
|
||||
|
||||
/// The feature flags currently used in the app.
|
||||
private var featureFlags: some View {
|
||||
ForEach(store.state.featureFlags) { flag in
|
||||
Toggle(
|
||||
isOn: store.bindingAsync(
|
||||
get: { _ in flag.isEnabled },
|
||||
perform: { DebugMenuEffect.toggleFeatureFlag(flag.feature.rawValue, $0) }
|
||||
)
|
||||
) {
|
||||
Text(flag.feature.name)
|
||||
}
|
||||
.toggleStyle(.bitwarden)
|
||||
.accessibilityIdentifier(flag.feature.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// The header for the feature flags section.
|
||||
private var featureFlagSectionHeader: some View {
|
||||
HStack {
|
||||
Text("Feature Flags")
|
||||
Spacer()
|
||||
AsyncButton {
|
||||
await store.perform(.refreshFeatureFlags)
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.accessibilityLabel("RefreshFeatureFlagsButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
DebugMenuView(
|
||||
store: Store(
|
||||
processor: StateProcessor(
|
||||
state: .init(
|
||||
featureFlags: [
|
||||
.init(
|
||||
feature: .enablePasswordManagerSync,
|
||||
isEnabled: true
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
82
AuthenticatorShared/UI/DebugMenu/DebugMenuViewTests.swift
Normal file
82
AuthenticatorShared/UI/DebugMenu/DebugMenuViewTests.swift
Normal file
@ -0,0 +1,82 @@
|
||||
import SnapshotTesting
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
// MARK: - DebugMenuViewTests
|
||||
|
||||
class DebugMenuViewTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var processor: MockProcessor<DebugMenuState, DebugMenuAction, DebugMenuEffect>!
|
||||
var subject: DebugMenuView!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
processor = MockProcessor(
|
||||
state: DebugMenuState(
|
||||
featureFlags: [
|
||||
.init(
|
||||
feature: .testLocalFeatureFlag,
|
||||
isEnabled: false
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
let store = Store(processor: processor)
|
||||
|
||||
subject = DebugMenuView(store: store)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
processor = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Tapping the close button dispatches the `.dismissTapped` action.
|
||||
@MainActor
|
||||
func test_closeButton_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.close)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .dismissTapped)
|
||||
}
|
||||
|
||||
/// Tests that the toggle fires off the correct effect.
|
||||
@MainActor
|
||||
func test_featureFlag_toggled() async throws {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
throw XCTSkip("Unable to run test in iOS 16, keep an eye on ViewInspector to see if it gets updated.")
|
||||
}
|
||||
let featureFlagName = FeatureFlag.testLocalFeatureFlag.rawValue
|
||||
let toggle = try subject.inspect().find(viewWithAccessibilityIdentifier: featureFlagName).toggle()
|
||||
try toggle.tap()
|
||||
XCTAssertEqual(processor.effects.last, .toggleFeatureFlag(featureFlagName, true))
|
||||
}
|
||||
|
||||
/// Test that the refresh button sends the correct effect.
|
||||
@MainActor
|
||||
func test_refreshFeatureFlags_tapped() async throws {
|
||||
let button = try subject.inspect().find(asyncButtonWithAccessibilityLabel: "RefreshFeatureFlagsButton")
|
||||
try await button.tap()
|
||||
XCTAssertEqual(processor.effects.last, .refreshFeatureFlags)
|
||||
}
|
||||
|
||||
/// Check the snapshot when feature flags are enabled and disabled.
|
||||
@MainActor
|
||||
func test_snapshot_debugMenuWithFeatureFlags() {
|
||||
processor.state.featureFlags = [
|
||||
.init(
|
||||
feature: .enablePasswordManagerSync,
|
||||
isEnabled: true
|
||||
),
|
||||
]
|
||||
assertSnapshot(of: subject, as: .defaultPortrait)
|
||||
}
|
||||
}
|
||||
55
AuthenticatorShared/UI/DebugMenu/ShakeWindow.swift
Normal file
55
AuthenticatorShared/UI/DebugMenu/ShakeWindow.swift
Normal file
@ -0,0 +1,55 @@
|
||||
import UIKit
|
||||
|
||||
/// A UIWindow subclass that detects and responds to shake gestures.
|
||||
///
|
||||
/// This window class allows you to provide a custom handler that will be called whenever a shake
|
||||
/// gesture is detected. This can be particularly useful for triggering debug or testing actions only
|
||||
/// in DEBUG_MENU mode, such as showing development menus or refreshing data.
|
||||
///
|
||||
public class ShakeWindow: UIWindow {
|
||||
/// The callback to be invoked when a shake gesture is detected.
|
||||
public var onShakeDetected: (() -> Void)?
|
||||
|
||||
/// Initializes a new ShakeWindow with a specific window scene and an optional shake detection handler.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - windowScene: The UIWindowScene instance with which the window is associated.
|
||||
/// - onShakeDetected: An optional closure that gets called when a shake gesture is detected.
|
||||
///
|
||||
public init(
|
||||
windowScene: UIWindowScene,
|
||||
onShakeDetected: (() -> Void)?
|
||||
) {
|
||||
self.onShakeDetected = onShakeDetected
|
||||
super.init(windowScene: windowScene)
|
||||
}
|
||||
|
||||
/// Required initializer for UIWindow subclass. Not implemented as ShakeWindow requires
|
||||
/// a custom initialization method with shake detection handler.
|
||||
///
|
||||
/// - Parameter coder: An NSCoder instance for decoding the window.
|
||||
///
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// Overrides the default motionEnded function to detect shake motions.
|
||||
/// If a shake motion is detected and we are in DEBUG_MENU mode,
|
||||
/// the onShakeDetected closure is called.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - motion: An event-subtype constant indicating the kind of motion.
|
||||
/// - event: An object representing the event associated with the motion.
|
||||
///
|
||||
override public func motionEnded(
|
||||
_ motion: UIEvent.EventSubtype,
|
||||
with event: UIEvent?
|
||||
) {
|
||||
#if DEBUG_MENU
|
||||
if motion == .motionShake {
|
||||
onShakeDetected?()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@ -11,6 +11,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
|
||||
/// The types of modules used by this coordinator.
|
||||
typealias Module = AuthModule
|
||||
& DebugMenuModule
|
||||
& ItemListModule
|
||||
& TabModule
|
||||
& TutorialModule
|
||||
@ -76,6 +77,8 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
|
||||
func navigate(to route: AppRoute, context _: AnyObject?) {
|
||||
switch route {
|
||||
case .debugMenu:
|
||||
showDebugMenu()
|
||||
case let .tab(tabRoute):
|
||||
showTab(route: tabRoute)
|
||||
}
|
||||
@ -147,6 +150,31 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
navigationController.modalPresentationStyle = .overFullScreen
|
||||
rootNavigator?.rootViewController?.present(navigationController, animated: false)
|
||||
}
|
||||
|
||||
#if DEBUG_MENU
|
||||
/// Configures and presents the debug menu.
|
||||
///
|
||||
/// Initializes feedback generator for haptic feedback. Sets up a `UINavigationController`
|
||||
/// and creates / starts a `DebugMenuCoordinator` to manage the debug menu flow.
|
||||
/// Presents the navigation controller and triggers haptic feedback upon completion.
|
||||
///
|
||||
private func showDebugMenu() {
|
||||
let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||||
feedbackGenerator.prepare()
|
||||
let stackNavigator = UINavigationController()
|
||||
stackNavigator.navigationBar.prefersLargeTitles = true
|
||||
stackNavigator.modalPresentationStyle = .fullScreen
|
||||
let debugMenuCoordinator = module.makeDebugMenuCoordinator(stackNavigator: stackNavigator)
|
||||
debugMenuCoordinator.start()
|
||||
childCoordinator = debugMenuCoordinator
|
||||
|
||||
rootNavigator?.rootViewController?.topmostViewController().present(
|
||||
stackNavigator,
|
||||
animated: true,
|
||||
completion: { feedbackGenerator.impactOccurred() }
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - AuthCoordinatorDelegate
|
||||
|
||||
@ -73,4 +73,11 @@ public class AppProcessor {
|
||||
await coordinator.handleEvent(.didStart)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG_MENU
|
||||
/// Show the debug menu.
|
||||
public func showDebugMenu() {
|
||||
coordinator?.navigate(to: .debugMenu)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
/// A top level route from the initial screen of the app to anywhere in the app.
|
||||
///
|
||||
public enum AppRoute: Equatable {
|
||||
/// A route to the debug menu.
|
||||
case debugMenu
|
||||
|
||||
/// A route to the tab interface.
|
||||
case tab(TabRoute)
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ class FileSelectionCoordinator: NSObject, Coordinator, HasStackNavigator {
|
||||
viewController.delegate = self
|
||||
stackNavigator?.present(viewController)
|
||||
} else {
|
||||
// TODO: BIT-1466 Present an alert about camera permissions being needed.
|
||||
// TODO: BWA-92 Present an alert about camera permissions being needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
#include "./Common.xcconfig"
|
||||
#include "./Base-Debug.xcconfig"
|
||||
#include? "./Local.xcconfig"
|
||||
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
|
||||
CODE_SIGN_ENTITLEMENTS = Authenticator/Application/Support/Entitlements/Authenticator.entitlements
|
||||
FIREBASE_CONFIG_FILENAME = GoogleService-Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID)
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS $(BITWARDEN_FLAGS)
|
||||
|
||||
@ -2,10 +2,11 @@
|
||||
CODE_SIGN_IDENTITY = Apple Development
|
||||
|
||||
#include "./Common.xcconfig"
|
||||
#include "./Base-Release.xcconfig"
|
||||
#include? "./Local.xcconfig"
|
||||
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
|
||||
CODE_SIGN_ENTITLEMENTS = Authenticator/Application/Support/Entitlements/Authenticator.entitlements
|
||||
FIREBASE_CONFIG_FILENAME = GoogleService-Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bitwarden.authenticator
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS $(BITWARDEN_FLAGS)
|
||||
|
||||
6
Configs/AuthenticatorShared-Debug.xcconfig
Normal file
6
Configs/AuthenticatorShared-Debug.xcconfig
Normal file
@ -0,0 +1,6 @@
|
||||
#include "./Common.xcconfig"
|
||||
#include "./Base-Debug.xcconfig"
|
||||
#include? "./Local.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).authenticator-shared
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)
|
||||
6
Configs/AuthenticatorShared-Release.xcconfig
Normal file
6
Configs/AuthenticatorShared-Release.xcconfig
Normal file
@ -0,0 +1,6 @@
|
||||
#include "./Common.xcconfig"
|
||||
#include "./Base-Release.xcconfig"
|
||||
#include? "./Local.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).authenticator-shared
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)
|
||||
@ -1,4 +0,0 @@
|
||||
#include "./Common.xcconfig"
|
||||
#include? "./Local.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bitwarden.authenticator.authenticator-shared
|
||||
1
Configs/Base-Debug.xcconfig
Normal file
1
Configs/Base-Debug.xcconfig
Normal file
@ -0,0 +1 @@
|
||||
BITWARDEN_FLAGS = DEBUG_MENU
|
||||
1
Configs/Base-Release.xcconfig
Normal file
1
Configs/Base-Release.xcconfig
Normal file
@ -0,0 +1 @@
|
||||
BITWARDEN_FLAGS =
|
||||
@ -5,6 +5,7 @@
|
||||
class MockAppModule:
|
||||
AppModule,
|
||||
AuthModule,
|
||||
DebugMenuModule,
|
||||
FileSelectionModule,
|
||||
ItemListModule,
|
||||
TutorialModule,
|
||||
@ -12,6 +13,7 @@ class MockAppModule:
|
||||
var appCoordinator = MockCoordinator<AppRoute, AppEvent>()
|
||||
var authCoordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
var authRouter = MockRouter<AuthEvent, AuthRoute>(routeForEvent: { _ in .vaultUnlock })
|
||||
var debugMenuCoordinator = MockCoordinator<DebugMenuRoute, Void>()
|
||||
var fileSelectionDelegate: FileSelectionDelegate?
|
||||
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, FileSelectionEvent>()
|
||||
var itemListCoordinator = MockCoordinator<ItemListRoute, ItemListEvent>()
|
||||
@ -37,6 +39,12 @@ class MockAppModule:
|
||||
authRouter.asAnyRouter()
|
||||
}
|
||||
|
||||
func makeDebugMenuCoordinator(
|
||||
stackNavigator: StackNavigator
|
||||
) -> AnyCoordinator<DebugMenuRoute, Void> {
|
||||
debugMenuCoordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
func makeFileSelectionCoordinator(
|
||||
delegate: FileSelectionDelegate,
|
||||
stackNavigator _: StackNavigator
|
||||
|
||||
160
GlobalTestHelpers/Support/InvocationMocker.swift
Normal file
160
GlobalTestHelpers/Support/InvocationMocker.swift
Normal file
@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Errors that the invocation mockers can throw.
|
||||
public enum InvocationMockerError: LocalizedError {
|
||||
case paramVerificationFailed
|
||||
case resultNotSet
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .paramVerificationFailed:
|
||||
return "The verification of the parameter failed."
|
||||
case .resultNotSet:
|
||||
return "The result of the InvocationMocker has not been set yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mocker of a func invocation that has one parameter.
|
||||
/// This is useful for tests where we need to verify a correct parameter is passed on invocation.
|
||||
class InvocationMocker<TParam> {
|
||||
var invokedParam: TParam?
|
||||
var called = false
|
||||
|
||||
/// Executes the `verification` and if it passes returns the `result`, throwing otherwise.
|
||||
/// - Parameter param: The parameter of the function to invoke.
|
||||
/// - Returns: Returns the result setup.
|
||||
func invoke(param: TParam?) {
|
||||
called = true
|
||||
invokedParam = param
|
||||
}
|
||||
|
||||
/// Asserts by verifying the parameter which was passed to the invoked function.
|
||||
/// - Parameters:
|
||||
/// - verification: Verification to run.
|
||||
/// - message: Message if fails.
|
||||
/// - file: File where this was originated.
|
||||
/// - line: Line number where this was originated.
|
||||
func assert(
|
||||
verification: (TParam?) -> Bool,
|
||||
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
XCTAssert(verification(invokedParam), message(), file: file, line: line)
|
||||
}
|
||||
|
||||
/// Asserts by verifying the parameter which was passed to the invoked function.
|
||||
/// This unwraps the parameter, but if can't be done then fails.
|
||||
/// - Parameters:
|
||||
/// - verification: Verification to run.
|
||||
/// - message: Message if fails.
|
||||
/// - file: File where this was originated.
|
||||
/// - line: Line number where this was originated.
|
||||
func assertUnwrapping(
|
||||
verification: (TParam) -> Bool,
|
||||
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
guard let invokedParam else {
|
||||
XCTFail("\(TParam.self) verification failed because parameter has not been set.")
|
||||
return
|
||||
}
|
||||
XCTAssert(verification(invokedParam), message(), file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mocker of a func invocation that has one parameter, a result and can throw.
|
||||
/// This is useful for tests where we need to verify a correct parameter is passed
|
||||
/// to return the correct result.
|
||||
class InvocationMockerWithThrowingResult<TParam, TResult> {
|
||||
var called = false
|
||||
var invokedParam: TParam?
|
||||
var result: (TParam) throws -> TResult = { _ in throw InvocationMockerError.resultNotSet }
|
||||
var verification: (TParam) -> Bool = { _ in true }
|
||||
|
||||
/// Asserts by verifying the parameter which was passed to the invoked function.
|
||||
/// - Parameters:
|
||||
/// - verification: Verification to run.
|
||||
/// - message: Message if fails.
|
||||
/// - file: File where this was originated.
|
||||
/// - line: Line number where this was originated.
|
||||
func assert(
|
||||
verification: (TParam?) -> Bool,
|
||||
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
XCTAssert(verification(invokedParam), message(), file: file, line: line)
|
||||
}
|
||||
|
||||
/// Asserts by verifying the parameter which was passed to the invoked function.
|
||||
/// This unwraps the parameter, but if can't be done then fails.
|
||||
/// - Parameters:
|
||||
/// - verification: Verification to run.
|
||||
/// - message: Message if fails.
|
||||
/// - file: File where this was originated.
|
||||
/// - line: Line number where this was originated.
|
||||
func assertUnwrapping(
|
||||
verification: (TParam) -> Bool,
|
||||
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
guard let invokedParam else {
|
||||
XCTFail("\(TParam.self) verification failed because parameter has not been set.")
|
||||
return
|
||||
}
|
||||
XCTAssert(verification(invokedParam), message(), file: file, line: line)
|
||||
}
|
||||
|
||||
/// Executes the `verification` and if it passes returns the `result`, throwing otherwise.
|
||||
/// - Parameter param: The parameter of the function to invoke.
|
||||
/// - Returns: Returns the result setup.
|
||||
func invoke(param: TParam) throws -> TResult {
|
||||
called = true
|
||||
guard verification(param) else {
|
||||
XCTFail("\(TParam.self) verification failed.")
|
||||
throw InvocationMockerError.paramVerificationFailed
|
||||
}
|
||||
invokedParam = param
|
||||
return try result(param)
|
||||
}
|
||||
|
||||
/// Sets up a verification to be executed and needs to pass in order to return the result.
|
||||
/// - Parameter verification: Verification to run.
|
||||
/// - Returns: `Self` for fluent coding.
|
||||
func withVerification(verification: @escaping (TParam) -> Bool) -> Self {
|
||||
self.verification = verification
|
||||
return self
|
||||
}
|
||||
|
||||
/// Sets up the result that will be returned if the verification passes.
|
||||
/// - Parameter result: The result to return.
|
||||
/// - Returns: `Self` for fluent coding
|
||||
@discardableResult
|
||||
func withResult(_ result: TResult) -> Self {
|
||||
self.result = { _ in result }
|
||||
return self
|
||||
}
|
||||
|
||||
/// Sets up the result that will be returned if the verification passes.
|
||||
/// - Parameter resultFunc: The result func to execute.
|
||||
/// - Returns: `Self` for fluent coding
|
||||
@discardableResult
|
||||
func withResult(_ resultFunc: @escaping (TParam) throws -> TResult) -> Self {
|
||||
result = resultFunc
|
||||
return self
|
||||
}
|
||||
|
||||
/// Sets up the error to throw if the verification passes.
|
||||
/// - Parameter error: The error to throw.
|
||||
/// - Returns: `Self` for fluent coding
|
||||
@discardableResult
|
||||
func throwing(_ error: Error) -> Self {
|
||||
result = { _ in throw error }
|
||||
return self
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
name: Authenticator
|
||||
fileGroups:
|
||||
- Configs
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
@ -149,8 +151,8 @@ targets:
|
||||
type: framework
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Configs/AuthenticatorShared.xcconfig
|
||||
Release: Configs/AuthenticatorShared.xcconfig
|
||||
Debug: Configs/AuthenticatorShared-Debug.xcconfig
|
||||
Release: Configs/AuthenticatorShared-Release.xcconfig
|
||||
settings:
|
||||
base:
|
||||
APPLICATION_EXTENSION_API_ONLY: true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user