diff --git a/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift b/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift index 89e64093d..46e827a22 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/AppPrivacyConfiguration.swift @@ -33,19 +33,26 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { private let locallyUnprotected: DomainsProtectionStore private let internalUserDecider: InternalUserDecider private let userDefaults: UserDefaults + private let locale: Locale + private let experimentManager: ExperimentCohortsManaging private let installDate: Date? + static let experimentManagerQueue = DispatchQueue(label: "com.experimentManager.queue") public init(data: PrivacyConfigurationData, identifier: String, localProtection: DomainsProtectionStore, internalUserDecider: InternalUserDecider, userDefaults: UserDefaults = UserDefaults(), + locale: Locale = Locale.current, + experimentManager: ExperimentCohortsManaging = ExperimentCohortsManager(), installDate: Date? = nil) { self.data = data self.identifier = identifier self.locallyUnprotected = localProtection self.internalUserDecider = internalUserDecider self.userDefaults = userDefaults + self.locale = locale + self.experimentManager = experimentManager self.installDate = installDate } @@ -137,13 +144,14 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { } } - private func isRolloutEnabled(subfeature: any PrivacySubfeature, + private func isRolloutEnabled(subfeatureID: SubfeatureID, + parentID: ParentFeatureID, rolloutSteps: [PrivacyConfigurationData.PrivacyFeature.Feature.RolloutStep], randomizer: (Range) -> Double) -> Bool { // Empty rollouts should be default enabled guard !rolloutSteps.isEmpty else { return true } - let defsPrefix = "config.\(subfeature.parent.rawValue).\(subfeature.rawValue)" + let defsPrefix = "config.\(parentID).\(subfeatureID)" if userDefaults.bool(forKey: "\(defsPrefix).\(Constants.enabledKey)") { return true } @@ -180,10 +188,10 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { } public func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, + cohortID: CohortID?, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { - - switch stateFor(subfeature, versionProvider: versionProvider, randomizer: randomizer) { + switch stateFor(subfeature, cohortID: cohortID, versionProvider: versionProvider, randomizer: randomizer) { case .enabled: return true case .disabled: @@ -191,17 +199,65 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { } } - public func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + public func getAllActiveExperiments(versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> Experiments { + Self.experimentManagerQueue.sync { + guard let assignedExperiments = experimentManager.experiments else { return [:] } + return assignedExperiments.filter { key, value in + stateFor(subfeatureID: key, experimentData: value, versionProvider: versionProvider, randomizer: randomizer) == .enabled + } + } + } - let parentState = stateFor(featureKey: subfeature.parent, versionProvider: versionProvider) - guard case .enabled = parentState else { return parentState } + private func stateFor(subfeatureID: SubfeatureID, experimentData: ExperimentData, versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + guard let parentFeature = PrivacyFeature(rawValue: experimentData.parentID), + let subfeatureData = subfeatures(for: parentFeature)[subfeatureID] else { + return .disabled(.featureMissing) + } + return stateFor(parentFeature: parentFeature, + subfeatureData: subfeatureData, + subfeatureID: subfeatureID, + cohortID: experimentData.cohort, + assignCohortEnabled: false, + versionProvider: versionProvider, + randomizer: randomizer) + } + + public func stateFor(_ subfeature: any PrivacySubfeature, + cohortID: CohortID?, + versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + Self.experimentManagerQueue.sync { + guard let subfeatureData = subfeatures(for: subfeature.parent)[subfeature.rawValue] else { + return .disabled(.featureMissing) + } + + return stateFor(parentFeature: subfeature.parent, + subfeatureData: subfeatureData, + subfeatureID: subfeature.rawValue, + cohortID: cohortID, + versionProvider: versionProvider, + randomizer: randomizer) + } + } - let subfeatures = subfeatures(for: subfeature.parent) - let subfeatureData = subfeatures[subfeature.rawValue] + private func stateFor(parentFeature: PrivacyFeature, + subfeatureData: PrivacyConfigurationData.PrivacyFeature.Feature, + subfeatureID: SubfeatureID, + cohortID: CohortID?, + assignCohortEnabled: Bool = true, + versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + // Step 1: Check parent feature state + let parentState = stateFor(featureKey: parentFeature, versionProvider: versionProvider) + guard case .enabled = parentState else { return parentState } - let satisfiesMinVersion = satisfiesMinVersion(subfeatureData?.minSupportedVersion, versionProvider: versionProvider) + // Step 2: Check version + let satisfiesMinVersion = satisfiesMinVersion(subfeatureData.minSupportedVersion, versionProvider: versionProvider) - switch subfeatureData?.state { + // Step 3: Check sub-feature state + switch subfeatureData.state { case PrivacyConfigurationData.State.enabled: guard satisfiesMinVersion else { return .disabled(.appVersionNotSupported) } case PrivacyConfigurationData.State.internal: @@ -210,15 +266,66 @@ public struct AppPrivacyConfiguration: PrivacyConfiguration { default: return .disabled(.disabledInConfig) } - // Handle Rollouts - if let rollout = subfeatureData?.rollout, - !isRolloutEnabled(subfeature: subfeature, rolloutSteps: rollout.steps, randomizer: randomizer) { + // Step 4: Handle Rollouts + if let rollout = subfeatureData.rollout, + !isRolloutEnabled(subfeatureID: subfeatureID, parentID: parentFeature.rawValue, rolloutSteps: rollout.steps, randomizer: randomizer) { return .disabled(.stillInRollout) } + // Step 5: Check if a cohort was passed in the func + // If no corhort passed check for Target and Rollout + guard let passedCohort = cohortID else { + return checkTargets(subfeatureData) + } + + // Step 5: Cohort handling + // Check if cohort assigned and matches passed cohort + // If cohort not assigned + // Tries to assign if matching target + // Check if cohort assigned and matches passed cohort + return checkCohortState(subfeatureData, + passedCohort: passedCohort, + assignCohortEnabled: assignCohortEnabled, + subfeatureID: subfeatureID, + parentFeature: parentFeature) + } + + // Check if cohort assigned and matches passed cohort + // If cohort not assigned + // Tries to assign if matching target + // Check if cohort assigned and matches passed cohort + private func checkCohortState(_ subfeatureData: PrivacyConfigurationData.PrivacyFeature.Feature, + passedCohort: CohortID?, + assignCohortEnabled: Bool, + subfeatureID: SubfeatureID, + parentFeature: PrivacyFeature) -> PrivacyConfigurationFeatureState { + let cohorts = subfeatureData.cohorts ?? [] + let targetsState = checkTargets(subfeatureData) + let assignIfEnabled = assignCohortEnabled && targetsState == .enabled + let assignedCohortResponse = experimentManager.cohort(for: ExperimentSubfeature(parentID: parentFeature.rawValue, subfeatureID: subfeatureID, cohorts: cohorts), assignIfEnabled: assignIfEnabled) + let possibleDisabledReason: PrivacyConfigurationFeatureDisabledReason = assignedCohortResponse.didAttemptAssignment && targetsState != .enabled ? .targetDoesNotMatch : .experimentCohortDoesNotMatch + if let assignedCohort = assignedCohortResponse.cohortID { + return (assignedCohort == passedCohort) ? .enabled : .disabled(possibleDisabledReason) + } else { + return .disabled(possibleDisabledReason) + } + } + + private func checkTargets(_ subfeatureData: PrivacyConfigurationData.PrivacyFeature.Feature?) -> PrivacyConfigurationFeatureState { + // Check Targets + if let targets = subfeatureData?.targets, !matchTargets(targets: targets){ + return .disabled(.targetDoesNotMatch) + } return .enabled } + private func matchTargets(targets: [PrivacyConfigurationData.PrivacyFeature.Feature.Target]) -> Bool { + return targets.contains { target in + (target.localeCountry == nil || target.localeCountry == locale.regionCode) && + (target.localeLanguage == nil || target.localeLanguage == locale.languageCode) + } + } + private func subfeatures(for feature: PrivacyFeature) -> PrivacyConfigurationData.PrivacyFeature.Features { return data.features[feature.rawValue]?.features ?? [:] } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift index abd01290b..e52e7b569 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift @@ -18,63 +18,78 @@ import Foundation -struct ExperimentSubfeature { +public struct ExperimentSubfeature { + let parentID: ParentFeatureID let subfeatureID: SubfeatureID let cohorts: [PrivacyConfigurationData.Cohort] } -typealias CohortID = String -typealias SubfeatureID = String +public typealias CohortID = String +public typealias SubfeatureID = String +public typealias ParentFeatureID = String -struct ExperimentData: Codable, Equatable { - let cohort: String - let enrollmentDate: Date +public struct ExperimentData: Codable, Equatable { + public let parentID: String + public let cohort: String + public let enrollmentDate: Date } -typealias Experiments = [String: ExperimentData] - -protocol ExperimentCohortsManaging { - /// Retrieves the cohort ID associated with the specified subfeature. - /// - Parameter subfeatureID: The name of the experiment subfeature for which the cohort ID is needed. - /// - Returns: The cohort ID as a `String` if one exists; otherwise, returns `nil`. - func cohort(for subfeatureID: SubfeatureID) -> CohortID? - - /// Retrieves the enrollment date for the specified subfeature. - /// - Parameter subfeatureID: The name of the experiment subfeature for which the enrollment date is needed. - /// - Returns: The `Date` of enrollment if one exists; otherwise, returns `nil`. - func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? - - /// Assigns a cohort to the given subfeature based on defined weights and saves it to UserDefaults. - /// - Parameter subfeature: The ExperimentSubfeature to which a cohort needs to be assigned to. - /// - Returns: The name of the assigned cohort, or `nil` if no cohort could be assigned. - func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID? - - /// Removes the assigned cohort data for the specified subfeature. - /// - Parameter subfeatureID: The name of the experiment subfeature for which the cohort data should be removed. - func removeCohort(from subfeatureID: SubfeatureID) +public typealias Experiments = [String: ExperimentData] + +public protocol ExperimentCohortsManaging { + /// Retrieves all the experiments a user is enrolled into + var experiments: Experiments? { get } + + /// Retrieves the assigned cohort for a given experiment subfeature, or attempts to assign a new cohort if none is currently assigned + /// and `assignIfEnabled` is set to true. If a cohort is already assigned but does not match any valid cohorts for the experiment, + /// the cohort will be removed. + /// + /// - Parameters: + /// - experiment: The `ExperimentSubfeature` for which to retrieve, assign, or remove a cohort. This subfeature includes + /// relevant identifiers and potential cohorts that may be assigned. + /// - assignIfEnabled: A Boolean value that determines whether a new cohort should be assigned if none is currently assigned. + /// If `true`, the function will attempt to assign a cohort from the available options; otherwise, it will only check for existing assignments. + /// + /// - Returns: A tuple containing: + /// - `cohortID`: The identifier of the assigned cohort if one exists, or `nil` if no cohort was assigned, if assignment failed, or if the cohort was removed. + /// - `didAttemptAssignment`: A Boolean indicating whether an assignment attempt was made. This will be `true` if `assignIfEnabled` + /// is `true` and no cohort was previously assigned, and `false` otherwise. + func cohort(for experiment: ExperimentSubfeature, assignIfEnabled: Bool) -> (cohortID: CohortID?, didAttemptAssignment: Bool) } -final class ExperimentCohortsManager: ExperimentCohortsManaging { +public class ExperimentCohortsManager: ExperimentCohortsManaging { private var store: ExperimentsDataStoring private let randomizer: (Range) -> Double + private let queue = DispatchQueue(label: "com.ExperimentCohortsManager.queue") - init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double) { + public var experiments: Experiments? { + get { + queue.sync { + store.experiments + } + } + } + + public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double = Double.random(in:)) { self.store = store self.randomizer = randomizer } - func cohort(for subfeatureID: SubfeatureID) -> CohortID? { - guard let experiments = store.experiments else { return nil } - return experiments[subfeatureID]?.cohort - } + public func cohort(for experiment: ExperimentSubfeature, assignIfEnabled: Bool) -> (cohortID: CohortID?, didAttemptAssignment: Bool) { + queue.sync { + let assignedCohort = cohort(for: experiment.subfeatureID) + if experiment.cohorts.contains(where: { $0.name == assignedCohort }) { + return (assignedCohort, false) + } else { + removeCohort(from: experiment.subfeatureID) + } - func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? { - guard let experiments = store.experiments else { return nil } - return experiments[subfeatureID]?.enrollmentDate + return assignIfEnabled ? (assignCohort(to: experiment), true) : (nil, true) + } } - func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID? { + private func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID? { let cohorts = subfeature.cohorts let totalWeight = cohorts.map(\.weight).reduce(0, +) guard totalWeight > 0 else { return nil } @@ -85,23 +100,34 @@ final class ExperimentCohortsManager: ExperimentCohortsManaging { for cohort in cohorts { cumulativeWeight += Double(cohort.weight) if randomValue < cumulativeWeight { - saveCohort(cohort.name, in: subfeature.subfeatureID) + saveCohort(cohort.name, in: subfeature.subfeatureID, parentID: subfeature.parentID) return cohort.name } } return nil } - func removeCohort(from subfeatureID: SubfeatureID) { + func cohort(for subfeatureID: SubfeatureID) -> CohortID? { + guard let experiments = store.experiments else { return nil } + return experiments[subfeatureID]?.cohort + } + + private func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? { + guard let experiments = store.experiments else { return nil } + return experiments[subfeatureID]?.enrollmentDate + } + + private func removeCohort(from subfeatureID: SubfeatureID) { guard var experiments = store.experiments else { return } experiments.removeValue(forKey: subfeatureID) store.experiments = experiments } - private func saveCohort(_ cohort: CohortID, in experimentID: SubfeatureID) { + private func saveCohort(_ cohort: CohortID, in experimentID: SubfeatureID, parentID: ParentFeatureID) { var experiments = store.experiments ?? Experiments() - let experimentData = ExperimentData(cohort: cohort, enrollmentDate: Date()) + let experimentData = ExperimentData(parentID: parentID, cohort: cohort, enrollmentDate: Date()) experiments[experimentID] = experimentData store.experiments = experiments } + } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift index bdf82819a..c99184df6 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift @@ -18,16 +18,16 @@ import Foundation -protocol ExperimentsDataStoring { +public protocol ExperimentsDataStoring { var experiments: Experiments? { get set } } -protocol LocalDataStoring { +public protocol LocalDataStoring { func data(forKey defaultName: String) -> Data? func set(_ value: Any?, forKey defaultName: String) } -struct ExperimentsDataStore: ExperimentsDataStoring { +public struct ExperimentsDataStore: ExperimentsDataStoring { private enum Constants { static let experimentsDataKey = "ExperimentsData" @@ -36,13 +36,13 @@ struct ExperimentsDataStore: ExperimentsDataStoring { private let decoder = JSONDecoder() private let encoder = JSONEncoder() - init(localDataStoring: LocalDataStoring = UserDefaults.standard) { + public init(localDataStoring: LocalDataStoring = UserDefaults.standard) { self.localDataStoring = localDataStoring encoder.dateEncodingStrategy = .secondsSince1970 decoder.dateDecodingStrategy = .secondsSince1970 } - var experiments: Experiments? { + public var experiments: Experiments? { get { guard let savedData = localDataStoring.data(forKey: Constants.experimentsDataKey) else { return nil } return try? decoder.decode(Experiments.self, from: savedData) diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index fc79ba107..4eda0d30e 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -19,7 +19,7 @@ import Foundation /// Features whose `rawValue` should be the key to access their corresponding `PrivacyConfigurationData.PrivacyFeature` object -public enum PrivacyFeature: String { +public enum PrivacyFeature: String, CaseIterable { case contentBlocking case duckPlayer case fingerprintingTemporaryStorage diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfiguration.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfiguration.swift index 37d5ace68..9131566e0 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfiguration.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfiguration.swift @@ -30,6 +30,8 @@ public enum PrivacyConfigurationFeatureDisabledReason: Equatable { case tooOldInstallation case limitedToInternalUsers case stillInRollout + case targetDoesNotMatch + case experimentCohortDoesNotMatch } public protocol PrivacyConfiguration { @@ -56,8 +58,8 @@ public protocol PrivacyConfiguration { func isEnabled(featureKey: PrivacyFeature, versionProvider: AppVersionProvider) -> Bool func stateFor(featureKey: PrivacyFeature, versionProvider: AppVersionProvider) -> PrivacyConfigurationFeatureState - func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState + func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, cohortID: CohortID?, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool + func stateFor(_ subfeature: any PrivacySubfeature, cohortID: CohortID?, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState /// Domains for which given PrivacyFeature is disabled. /// @@ -102,6 +104,10 @@ public protocol PrivacyConfiguration { func userEnabledProtection(forDomain: String) /// Adds given domain to locally unprotected list. func userDisabledProtection(forDomain: String) + + /// Gives the list of all the active experiments an user is enrolled in + func getAllActiveExperiments(versionProvider: AppVersionProvider, + randomizer: (Range) -> Double) -> Experiments } public extension PrivacyConfiguration { @@ -113,11 +119,15 @@ public extension PrivacyConfiguration { return stateFor(featureKey: featureKey, versionProvider: AppVersionProvider()) } - func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, randomizer: (Range) -> Double = Double.random(in:)) -> Bool { - return isSubfeatureEnabled(subfeature, versionProvider: AppVersionProvider(), randomizer: randomizer) + func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, cohortID: CohortID? = nil, randomizer: (Range) -> Double = Double.random(in:)) -> Bool { + return isSubfeatureEnabled(subfeature, cohortID: cohortID, versionProvider: AppVersionProvider(), randomizer: randomizer) + } + + func stateFor(_ subfeature: any PrivacySubfeature, cohortID: CohortID? = nil, randomizer: (Range) -> Double = Double.random(in:)) -> PrivacyConfigurationFeatureState { + return stateFor(subfeature, cohortID: cohortID, versionProvider: AppVersionProvider(), randomizer: randomizer) } - func stateFor(_ subfeature: any PrivacySubfeature, randomizer: (Range) -> Double = Double.random(in:)) -> PrivacyConfigurationFeatureState { - return stateFor(subfeature, versionProvider: AppVersionProvider(), randomizer: randomizer) + func getAllActiveExperiments(versionProvider: AppVersionProvider = AppVersionProvider(), randomizer: (Range) -> Double = Double.random(in:)) -> Experiments { + return getAllActiveExperiments(versionProvider: versionProvider, randomizer: randomizer) } } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift index 7cbd2bc71..f6f91567f 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationData.swift @@ -136,6 +136,8 @@ public struct PrivacyConfigurationData { case minSupportedVersion case rollout case cohorts + case targets + case settings } public struct Rollout: Hashable { @@ -169,10 +171,27 @@ public struct PrivacyConfigurationData { } } + public struct Target { + enum CodingKeys: String { + case localeCountry + case localeLanguage + } + + public let localeCountry: String? + public let localeLanguage: String? + + public init(json: [String: Any]) { + self.localeCountry = json[CodingKeys.localeCountry.rawValue] as? String + self.localeLanguage = json[CodingKeys.localeLanguage.rawValue] as? String + } + } + public let state: FeatureState public let minSupportedVersion: FeatureSupportedVersion? public let rollout: Rollout? public let cohorts: [Cohort]? + public let targets: [Target]? + public let settings: String? public init?(json: [String: Any]) { guard let state = json[CodingKeys.state.rawValue] as? String else { @@ -194,6 +213,19 @@ public struct PrivacyConfigurationData { } else { cohorts = nil } + + if let targetData = json[CodingKeys.targets.rawValue] as? [[String: Any]] { + targets = targetData.compactMap { Target(json: $0) } + } else { + targets = nil + } + + if let settingsData = json[CodingKeys.settings.rawValue] { + let jsonData = try? JSONSerialization.data(withJSONObject: settingsData, options: []) + settings = jsonData != nil ? String(data: jsonData!, encoding: .utf8) : nil + } else { + settings = nil + } } } diff --git a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift index 219a01613..fff2f1482 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/PrivacyConfigurationManager.swift @@ -55,6 +55,8 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { private let localProtection: DomainsProtectionStore private let errorReporting: EventMapping? private let installDate: Date? + private let locale: Locale + private let experimentCohortManager: ExperimentCohortsManaging public let internalUserDecider: InternalUserDecider @@ -110,12 +112,16 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { localProtection: DomainsProtectionStore, errorReporting: EventMapping? = nil, internalUserDecider: InternalUserDecider, + locale: Locale = Locale.current, + experimentCohortManager: ExperimentCohortsManaging = ExperimentCohortsManager(store: ExperimentsDataStore()), installDate: Date? = nil ) { self.embeddedDataProvider = embeddedDataProvider self.localProtection = localProtection self.errorReporting = errorReporting self.internalUserDecider = internalUserDecider + self.experimentCohortManager = experimentCohortManager + self.locale = locale self.installDate = installDate reload(etag: fetchedETag, data: fetchedData) @@ -127,6 +133,8 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { identifier: fetchedData.etag, localProtection: localProtection, internalUserDecider: internalUserDecider, + locale: locale, + experimentManager: experimentCohortManager, installDate: installDate) } @@ -134,6 +142,8 @@ public class PrivacyConfigurationManager: PrivacyConfigurationManaging { identifier: embeddedConfigData.etag, localProtection: localProtection, internalUserDecider: internalUserDecider, + locale: locale, + experimentManager: experimentCohortManager, installDate: installDate) } diff --git a/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift b/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift index 9a3d7610c..76ecc8b87 100644 --- a/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift +++ b/Tests/BrokenSitePromptTests/PrivacyConfigurationManagerMock.swift @@ -55,12 +55,12 @@ class PrivacyConfigurationMock: PrivacyConfiguration { } var enabledSubfeaturesForVersions: [String: Set] = [:] - func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { + func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, cohortID: CohortID?, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { return enabledSubfeaturesForVersions[subfeature.rawValue]?.contains(versionProvider.appVersion() ?? "") ?? false } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { - if isSubfeatureEnabled(subfeature, versionProvider: versionProvider, randomizer: randomizer) { + func stateFor(_ subfeature: any PrivacySubfeature, cohortID: CohortID?, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + if isSubfeatureEnabled(subfeature, cohortID: cohortID, versionProvider: versionProvider, randomizer: randomizer) { return .enabled } return .disabled(.disabledInConfig) // this is not used in platform tests, so mocking this poorly for now diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift b/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift index e1cb475cd..78d527308 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/UserContentControllerTests.swift @@ -318,15 +318,11 @@ class PrivacyConfigurationMock: PrivacyConfiguration { return .enabled } - func isSubfeatureEnabled( - _ subfeature: any PrivacySubfeature, - versionProvider: AppVersionProvider, - randomizer: (Range) -> Double - ) -> Bool { + func isSubfeatureEnabled(_ subfeature: any BrowserServicesKit.PrivacySubfeature, cohortID: BrowserServicesKit.CohortID?, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> Bool { true } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any BrowserServicesKit.PrivacySubfeature, cohortID: BrowserServicesKit.CohortID?, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { return .enabled } diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift b/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift index 31370ce4a..db78351d9 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/WebViewTestHelper.swift @@ -225,3 +225,11 @@ final class WebKitTestHelper { } } } + +class MockExperimentCohortsManager: ExperimentCohortsManaging { + var experiments: BrowserServicesKit.Experiments? + + func cohort(for experiment: BrowserServicesKit.ExperimentSubfeature, assignIfEnabled: Bool) -> (cohortID: BrowserServicesKit.CohortID?, didAttemptAssignment: Bool) { + return (nil, true) + } +} diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/AddPrivacyConfigurationExperimentTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/AddPrivacyConfigurationExperimentTests.swift new file mode 100644 index 000000000..8a4591fe7 --- /dev/null +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/AddPrivacyConfigurationExperimentTests.swift @@ -0,0 +1,1226 @@ +// +// AddPrivacyConfigurationExperimentTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import BrowserServicesKit + +final class AddPrivacyConfigurationExperimentTests: XCTestCase { + + var featureJson: Data = "{}".data(using: .utf8)! + var mockEmbeddedData: MockEmbeddedDataProvider! + var mockStore: MockExperimentDataStore! + var experimentManager: ExperimentCohortsManager! + var manager: PrivacyConfigurationManager! + var locale: Locale! + + let subfeatureName = "credentialsSaving" + + override func setUp() { + locale = Locale(identifier: "fr_US") + mockEmbeddedData = MockEmbeddedDataProvider(data: featureJson, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + mockStore = MockExperimentDataStore() + experimentManager = ExperimentCohortsManager(store: mockStore) + manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale, + experimentCohortManager: experimentManager) + } + + override func tearDown() { + featureJson = "".data(using: .utf8)! + mockEmbeddedData = nil + mockStore = nil + experimentManager = nil + manager = nil + } + + func testCohortOnlyAssignedWhenCallingStateForSubfeature() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + let config = manager.privacyConfig + + // we haven't called isEnabled yet, so cohorts should not be yet assigned + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // we call isEnabled() without cohort, cohort should not be assigned either + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // we call isEnabled(cohort), then we should assign cohort + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving, cohortID: "blue"), .disabled(.experimentCohortDoesNotMatch)) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + } + + func testRemoveAllCohortsRemotelyRemovesAssignedCohort() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + var config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove blue cohort + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove all remaining cohorts + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2 + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? false) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + } + + func testRemoveAssignedCohortsRemotelyRemovesAssignedCohortAndTriesToReassign() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "2", data: featureJson) + var config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove blue cohort + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "red", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "2", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "red")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "red") + } + + func testDisablingFeatureDisablesCohort() { + // Initially subfeature for both cohorts is disabled + var config = manager.privacyConfig + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // When features with cohort the cohort with weight 1 is enabled + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // If the subfeature is then disabled isSubfeatureEnabled should return false + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "disabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // If the subfeature is parent feature disabled isSubfeatureEnabled should return false + featureJson = + """ + { + "features": { + "autofill": { + "state": "disabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + } + + func testCohortsAndTargetsInteraction() { + func featureJson(country: String, language: String) -> Data { + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeLanguage": "\(language)", + "localeCountry": "\(country)" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + } + manager.reload(etag: "", data: featureJson(country: "FR", language: "fr")) + var config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + manager.reload(etag: "", data: featureJson(country: "US", language: "en")) + config = manager.privacyConfig + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertNil(mockStore.experiments) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + manager.reload(etag: "", data: featureJson(country: "US", language: "fr")) + config = manager.privacyConfig + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // once cohort is assigned, changing targets shall not affect feature state + manager.reload(etag: "", data: featureJson(country: "IT", language: "it")) + config = manager.privacyConfig + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + let featureJson2 = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "FR" + } + ], + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson2) + config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? false) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + + // re-populate experiment to re-assign new cohort, should not be assigned as it has wrong targets + manager.reload(etag: "", data: featureJson(country: "IT", language: "it")) + config = manager.privacyConfig + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? false) + XCTAssertNil(experimentManager.cohort(for: subfeatureName)) + } + + func testChangeRemoteCohortsAfterAssignmentShouldNoop() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + var config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // changing targets should not change cohort assignment + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "IT" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // changing cohort weight should not change current assignment + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "blue", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // adding cohorts should not change current assignment + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 1 + }, + { + "name": "red", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + } + + func testEnrollmentDate() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "", data: featureJson) + let config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertTrue(mockStore.experiments?.isEmpty ?? true) + XCTAssertNil(experimentManager.cohort(for: subfeatureName), "control") + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + let currentTime = Date().timeIntervalSince1970 + let enrollmentTime = mockStore.experiments?[subfeatureName]?.enrollmentDate.timeIntervalSince1970 + + XCTAssertNotNil(enrollmentTime) + if let enrollmentTime = enrollmentTime { + let tolerance: TimeInterval = 60 // 1 minute in seconds + XCTAssertEqual(currentTime, enrollmentTime, accuracy: tolerance) + } + } + + func testRollbackCohortExperiments() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + }, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + var config = manager.privacyConfig + clearRolloutData(feature: "autofill", subFeature: "credentialsSaving") + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "rollout": { + "steps": [ + { + "percent": 0 + } + ] + }, + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + } + + func testCohortEnabledAndStopEnrollmentAndRhenRollBack() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + var config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // Stop enrollment, should keep assigned cohorts + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "blue", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "control") + + // remove control, should re-allocate to blue + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "blue", + "weight": 1 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "blue")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: subfeatureName), "blue") + } + + func clearRolloutData(feature: String, subFeature: String) { + UserDefaults().set(nil, forKey: "config.\(feature).\(subFeature).enabled") + UserDefaults().set(nil, forKey: "config.\(feature).\(subFeature).lastRolloutCount") + } + + func testAllActiveExperimentsEmptyIfNoAssignedExperiment() { + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + manager.reload(etag: "foo", data: featureJson) + let config = manager.privacyConfig + + let activeExperiments = config.getAllActiveExperiments() + XCTAssertTrue(activeExperiments.isEmpty) + XCTAssertNil(mockStore.experiments) + } + + func testAllActiveExperimentsReturnsOnlyActiveExperiments() { + var featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "blue", + "weight": 0 + } + ] + }, + "inlineIconCredentials": { + "state": "enabled", + "minSupportedVersion": 1, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "green", + "weight": 1 + } + ] + }, + "accessCredentialManagement": { + "state": "enabled", + "minSupportedVersion": 3, + "targets": [ + { + "localeCountry": "CA" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "green", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + manager.reload(etag: "foo", data: featureJson) + var config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: "control")) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.inlineIconCredentials, cohortID: "green")) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.accessCredentialManagement, cohortID: "control")) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + XCTAssertEqual(experimentManager.cohort(for: AutofillSubfeature.credentialsSaving.rawValue), "control") + XCTAssertEqual(experimentManager.cohort(for: AutofillSubfeature.inlineIconCredentials.rawValue), "green") + XCTAssertNil(experimentManager.cohort(for: AutofillSubfeature.accessCredentialManagement.rawValue)) + XCTAssertFalse(mockStore.experiments?.isEmpty ?? true) + + var activeExperiments = config.getAllActiveExperiments() + XCTAssertEqual(activeExperiments.count, 2) + XCTAssertEqual(activeExperiments[AutofillSubfeature.credentialsSaving.rawValue]?.cohort, "control") + XCTAssertEqual(activeExperiments[AutofillSubfeature.inlineIconCredentials.rawValue]?.cohort, "green") + XCTAssertNil(activeExperiments[AutofillSubfeature.accessCredentialManagement.rawValue]) + + // When an assigned cohort is removed it's not part of active experiments + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "blue", + "weight": 1 + } + ] + }, + "inlineIconCredentials": { + "state": "enabled", + "minSupportedVersion": 1, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "green", + "weight": 1 + } + ] + }, + "accessCredentialManagement": { + "state": "enabled", + "minSupportedVersion": 3, + "targets": [ + { + "localeCountry": "CA" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "green", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + activeExperiments = config.getAllActiveExperiments() + XCTAssertEqual(activeExperiments.count, 1) + XCTAssertNil(activeExperiments[AutofillSubfeature.credentialsSaving.rawValue]) + XCTAssertEqual(activeExperiments[AutofillSubfeature.inlineIconCredentials.rawValue]?.cohort, "green") + XCTAssertNil(activeExperiments[AutofillSubfeature.accessCredentialManagement.rawValue]) + + // When feature disabled an assigned cohort it's not part of active experiments + featureJson = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "minSupportedVersion": 2, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "blue", + "weight": 1 + } + ] + }, + "inlineIconCredentials": { + "state": "disabled", + "minSupportedVersion": 1, + "targets": [ + { + "localeCountry": "US" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 0 + }, + { + "name": "green", + "weight": 1 + } + ] + }, + "accessCredentialManagement": { + "state": "enabled", + "minSupportedVersion": 3, + "targets": [ + { + "localeCountry": "CA" + } + ], + "cohorts": [ + { + "name": "control", + "weight": 1 + }, + { + "name": "green", + "weight": 0 + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + manager.reload(etag: "foo", data: featureJson) + config = manager.privacyConfig + + activeExperiments = config.getAllActiveExperiments() + XCTAssertTrue(activeExperiments.isEmpty) + } + +} diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift index 252a7b866..e3be10a1f 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/AppPrivacyConfigurationTests.swift @@ -586,13 +586,13 @@ class AppPrivacyConfigurationTests: XCTestCase { let config = manager.privacyConfig let oldVersionProvider = MockAppVersionProvider(appVersion: "1.35.0") - XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, versionProvider: oldVersionProvider, randomizer: Double.random(in:))) - XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving, versionProvider: oldVersionProvider, randomizer: Double.random(in:)), .disabled(.appVersionNotSupported)) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: oldVersionProvider, randomizer: Double.random(in:))) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: oldVersionProvider, randomizer: Double.random(in:)), .disabled(.appVersionNotSupported)) let currentVersionProvider = MockAppVersionProvider(appVersion: "1.36.0") - XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, versionProvider: currentVersionProvider, randomizer: Double.random(in:))) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: currentVersionProvider, randomizer: Double.random(in:))) XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) let futureVersionProvider = MockAppVersionProvider(appVersion: "2.16.0") - XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, versionProvider: futureVersionProvider, randomizer: Double.random(in:))) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: futureVersionProvider, randomizer: Double.random(in:))) XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) } @@ -661,12 +661,12 @@ class AppPrivacyConfigurationTests: XCTestCase { let oldVersionProvider = MockAppVersionProvider(appVersion: "1.35.0") XCTAssertFalse(config.isEnabled(featureKey: .autofill, versionProvider: oldVersionProvider)) - XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, versionProvider: oldVersionProvider, randomizer: Double.random(in:))) - XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving, versionProvider: oldVersionProvider, randomizer: Double.random(in:)), .disabled(.appVersionNotSupported)) + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: oldVersionProvider, randomizer: Double.random(in:))) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: oldVersionProvider, randomizer: Double.random(in:)), .disabled(.appVersionNotSupported)) let currentVersionProvider = MockAppVersionProvider(appVersion: "1.36.0") XCTAssertTrue(config.isEnabled(featureKey: .autofill, versionProvider: currentVersionProvider)) - XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, versionProvider: currentVersionProvider, randomizer: Double.random(in:))) + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, cohortID: nil, versionProvider: currentVersionProvider, randomizer: Double.random(in:))) XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) } @@ -907,6 +907,186 @@ class AppPrivacyConfigurationTests: XCTestCase { XCTAssertEqual(configAfterUpdate.stateFor(AutofillSubfeature.credentialsSaving, randomizer: mockRandom(in:)), .disabled(.disabledInConfig)) } + let exampleSubfeatureEnabledWithTarget = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledWhenTargetMatches_SubfeatureShouldBeEnabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) + } + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledWhenRegionDoesNotMatches_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_FR") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.targetDoesNotMatch)) + } + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledWhenLanguageDoesNotMatches_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "it_US") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.targetDoesNotMatch)) + } + + let exampleSubfeatureDisabledWithTarget = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "disabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + func testWhenCheckingSubfeatureStateWithSubfeatureDisabledWhenTargetMatches_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureDisabledWithTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving)) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.disabledInConfig)) + } + + let exampleSubfeatureEnabledWithRolloutAndTarget = + """ + { + "features": { + "autofill": { + "state": "enabled", + "exceptions": [], + "features": { + "credentialsSaving": { + "state": "enabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ], + "rollout": { + "steps": [{ + "percent": 5.0 + }] + } + } + } + } + } + } + """.data(using: .utf8)! + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledAndTargetMatchesWhenNotInRollout_SubfeatureShouldBeDisabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithRolloutAndTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + mockRandomValue = 7.0 + clearRolloutData(feature: "autofill", subFeature: "credentialsSaving") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertFalse(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, randomizer: mockRandom(in:))) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .disabled(.stillInRollout)) + } + + func testWhenCheckingSubfeatureStateWithSubfeatureEnabledAndTargetMatchesWhenInRollout_SubfeatureShouldBeEnabled() { + let mockEmbeddedData = MockEmbeddedDataProvider(data: exampleSubfeatureEnabledWithRolloutAndTarget, etag: "test") + let mockInternalUserStore = MockInternalUserStoring() + let locale = Locale(identifier: "fr_US") + mockRandomValue = 2.0 + clearRolloutData(feature: "autofill", subFeature: "credentialsSaving") + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: mockEmbeddedData, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider(store: mockInternalUserStore), + locale: locale) + let config = manager.privacyConfig + + XCTAssertTrue(config.isSubfeatureEnabled(AutofillSubfeature.credentialsSaving, randomizer: mockRandom(in:))) + XCTAssertEqual(config.stateFor(AutofillSubfeature.credentialsSaving), .enabled) + } + let exampleEnabledSubfeatureWithRollout = """ { diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift index 518249560..0a5114d50 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentCohortsManagerTests.swift @@ -21,6 +21,11 @@ import XCTest final class ExperimentCohortsManagerTests: XCTestCase { + let cohort1 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort1", "weight": 1])! + let cohort2 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort2", "weight": 0])! + let cohort3 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort3", "weight": 2])! + let cohort4 = PrivacyConfigurationData.Cohort(json: ["name": "Cohort4", "weight": 0])! + var mockStore: MockExperimentDataStore! var experimentCohortsManager: ExperimentCohortsManager! @@ -30,6 +35,12 @@ final class ExperimentCohortsManagerTests: XCTestCase { let subfeatureName2 = "TestSubfeature2" var experimentData2: ExperimentData! + let subfeatureName3 = "TestSubfeature3" + var experimentData3: ExperimentData! + + let subfeatureName4 = "TestSubfeature4" + var experimentData4: ExperimentData! + let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .secondsSince1970 @@ -40,15 +51,20 @@ final class ExperimentCohortsManagerTests: XCTestCase { super.setUp() mockStore = MockExperimentDataStore() experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { _ in 50.0 } + store: mockStore ) let expectedDate1 = Date() - experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: expectedDate1) + experimentData1 = ExperimentData(parentID: "TestParent", cohort: cohort1.name, enrollmentDate: expectedDate1) let expectedDate2 = Date().addingTimeInterval(60) - experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: expectedDate2) + experimentData2 = ExperimentData(parentID: "TestParent", cohort: cohort2.name, enrollmentDate: expectedDate2) + + let expectedDate3 = Date() + experimentData3 = ExperimentData(parentID: "TestParent", cohort: cohort3.name, enrollmentDate: expectedDate3) + + let expectedDate4 = Date().addingTimeInterval(60) + experimentData4 = ExperimentData(parentID: "TestParent", cohort: cohort4.name, enrollmentDate: expectedDate4) } override func tearDown() { @@ -59,206 +75,117 @@ final class ExperimentCohortsManagerTests: XCTestCase { super.tearDown() } - func testCohortReturnsCohortIDIfExistsForMultipleSubfeatures() { + func testExperimentReturnAssignedExperiments() { // GIVEN mockStore.experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] // WHEN - let result1 = experimentCohortsManager.cohort(for: subfeatureName1) - let result2 = experimentCohortsManager.cohort(for: subfeatureName2) + let experiments = experimentCohortsManager.experiments // THEN - XCTAssertEqual(result1, experimentData1.cohort) - XCTAssertEqual(result2, experimentData2.cohort) + XCTAssertEqual(experiments?.count, 2) + XCTAssertEqual(experiments?[subfeatureName1], experimentData1) + XCTAssertEqual(experiments?[subfeatureName2], experimentData2) + XCTAssertNil(experiments?[subfeatureName3]) } - func testEnrollmentDateReturnsCorrectDateIfExists() { + func testCohortReturnsCohortIDIfExistsForMultipleSubfeatures() { // GIVEN - mockStore.experiments = [subfeatureName1: experimentData1] + mockStore.experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] // WHEN - let result1 = experimentCohortsManager.enrollmentDate(for: subfeatureName1) - let result2 = experimentCohortsManager.enrollmentDate(for: subfeatureName2) + let result1 = experimentCohortsManager.cohort(for: ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: [cohort1, cohort2]), assignIfEnabled: false).cohortID + let result2 = experimentCohortsManager.cohort(for: ExperimentSubfeature(parentID: experimentData2.parentID, subfeatureID: subfeatureName2, cohorts: [cohort2, cohort3]), assignIfEnabled: false).cohortID // THEN - let timeDifference1 = abs(experimentData1.enrollmentDate.timeIntervalSince(result1 ?? Date())) - - XCTAssertLessThanOrEqual(timeDifference1, 1.0, "Expected enrollment date for subfeatureName1 to match at the second level") - XCTAssertNil(result2) + XCTAssertEqual(result1, experimentData1.cohort) + XCTAssertEqual(result2, experimentData2.cohort) } - func testCohortReturnsNilIfCohortDoesNotExist() { + func testCohortAssignIfEnabledWhenNoCohortExists() { // GIVEN - let subfeatureName = "TestSubfeature" + mockStore.experiments = [:] + let cohorts = [cohort1, cohort2] + let experiment = ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: cohorts) // WHEN - let result = experimentCohortsManager.cohort(for: subfeatureName) + let result = experimentCohortsManager.cohort(for: experiment, assignIfEnabled: true) // THEN - XCTAssertNil(result) + XCTAssertNotNil(result.cohortID) + XCTAssertTrue(result.didAttemptAssignment) + XCTAssertEqual(result.cohortID, experimentData1.cohort) } - func testEnrollmentDateReturnsNilIfDateDoesNotExist() { + func testCohortDoesNotAssignIfAssignIfEnabledIsFalse() { // GIVEN - let subfeatureName = "TestSubfeature" + mockStore.experiments = [:] + let cohorts = [cohort1, cohort2] + let experiment = ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: cohorts) // WHEN - let result = experimentCohortsManager.enrollmentDate(for: subfeatureName) + let result = experimentCohortsManager.cohort(for: experiment, assignIfEnabled: false) // THEN - XCTAssertNil(result) + XCTAssertNil(result.cohortID) + XCTAssertTrue(result.didAttemptAssignment) } - func testRemoveCohortSuccessfullyRemovesData() throws { + func testCohortDoesNotAssignIfAssignIfEnabledIsTrueButNoCohortsAvailable() { // GIVEN - mockStore.experiments = [subfeatureName1: experimentData1] + mockStore.experiments = [:] + let experiment = ExperimentSubfeature(parentID: "TestParent", subfeatureID: "NonExistentSubfeature", cohorts: []) // WHEN - experimentCohortsManager.removeCohort(from: subfeatureName1) + let result = experimentCohortsManager.cohort(for: experiment, assignIfEnabled: true) // THEN - let experiments = try XCTUnwrap(mockStore.experiments) - XCTAssertTrue(experiments.isEmpty) + XCTAssertNil(result.cohortID) + XCTAssertTrue(result.didAttemptAssignment) } - func testRemoveCohortDoesNothingIfSubfeatureDoesNotExist() { + func testCohortReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() { // GIVEN - let expectedExperiments: Experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] - mockStore.experiments = expectedExperiments + mockStore.experiments = [subfeatureName1: experimentData1] // WHEN - experimentCohortsManager.removeCohort(from: "someOtherSubfeature") + let result1 = experimentCohortsManager.cohort(for: ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: [cohort2, cohort3]), assignIfEnabled: true).cohortID // THEN - XCTAssertEqual( mockStore.experiments, expectedExperiments) + XCTAssertEqual(result1, experimentData3.cohort) } - func testAssignCohortReturnsNilIfNoCohorts() { + func testCohortDoesNotReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() { // GIVEN - let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: []) + mockStore.experiments = [subfeatureName1: experimentData1] // WHEN - let result = experimentCohortsManager.assignCohort(to: subfeature) + let result1 = experimentCohortsManager.cohort(for: ExperimentSubfeature(parentID: experimentData1.parentID, subfeatureID: subfeatureName1, cohorts: [cohort2, cohort3]), assignIfEnabled: false).cohortID // THEN - XCTAssertNil(result) + XCTAssertNil(result1) } - func testAssignCohortReturnsNilIfAllWeightsAreZero() { + func testCohortAssignsBasedOnWeight() { // GIVEN - let jsonCohort1: [String: Any] = ["name": "TestCohort", "weight": 0] - let jsonCohort2: [String: Any] = ["name": "TestCohort", "weight": 0] - let cohorts = [ - PrivacyConfigurationData.Cohort(json: jsonCohort1)!, - PrivacyConfigurationData.Cohort(json: jsonCohort2)! - ] - let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: cohorts) - - // WHEN - let result = experimentCohortsManager.assignCohort(to: subfeature) - - // THEN - XCTAssertNil(result) - } - - func testAssignCohortSelectsCorrectCohortBasedOnWeight() { - // Cohort1 has weight 1, Cohort2 has weight 3 - // Total weight is 1 + 3 = 4 - let jsonCohort1: [String: Any] = ["name": "Cohort1", "weight": 1] - let jsonCohort2: [String: Any] = ["name": "Cohort2", "weight": 3] - let cohorts = [ - PrivacyConfigurationData.Cohort(json: jsonCohort1)!, - PrivacyConfigurationData.Cohort(json: jsonCohort2)! - ] - let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: cohorts) - let expectedTotalWeight = 4.0 - - // Use a custom randomizer to verify the range - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { range in - // Assert that the range lower bound is 0 - XCTAssertEqual(range.lowerBound, 0.0) - // Assert that the range upper bound is the total weight - XCTAssertEqual(range.upperBound, expectedTotalWeight) - return 0.0 - } - ) - - // Test case where random value is at the very start of Cohort1's range (0) - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { _ in 0.0 } - ) - let resultStartOfCohort1 = experimentCohortsManager.assignCohort(to: subfeature) - XCTAssertEqual(resultStartOfCohort1, "Cohort1") - - // Test case where random value is at the end of Cohort1's range (0.9) - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { _ in 0.9 } - ) - let resultEndOfCohort1 = experimentCohortsManager.assignCohort(to: subfeature) - XCTAssertEqual(resultEndOfCohort1, "Cohort1") - - // Test case where random value is at the start of Cohort2's range (1.00 to 4) - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { _ in 1.00 } - ) - let resultStartOfCohort2 = experimentCohortsManager.assignCohort(to: subfeature) - XCTAssertEqual(resultStartOfCohort2, "Cohort2") + let experiment = ExperimentSubfeature(parentID: experimentData3.parentID, subfeatureID: subfeatureName3, cohorts: [cohort3, cohort4]) - // Test case where random value falls exactly within Cohort2's range (2.5) - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { _ in 2.5 } - ) - let resultMiddleOfCohort2 = experimentCohortsManager.assignCohort(to: subfeature) - XCTAssertEqual(resultMiddleOfCohort2, "Cohort2") + let randomizer: (Range) -> Double = { range in + return 1.5 + } - // Test case where random value is at the end of Cohort2's range (4) experimentCohortsManager = ExperimentCohortsManager( store: mockStore, - randomizer: { _ in 3.9 } - ) - let resultEndOfCohort2 = experimentCohortsManager.assignCohort(to: subfeature) - XCTAssertEqual(resultEndOfCohort2, "Cohort2") - } - - func testAssignCohortWithSingleCohortAlwaysSelectsThatCohort() throws { - // GIVEN - let jsonCohort1: [String: Any] = ["name": "Cohort1", "weight": 1] - let cohorts = [ - PrivacyConfigurationData.Cohort(json: jsonCohort1)! - ] - let subfeature = ExperimentSubfeature(subfeatureID: subfeatureName1, cohorts: cohorts) - let expectedTotalWeight = 1.0 - - // Use a custom randomizer to verify the range - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { range in - // Assert that the range lower bound is 0 - XCTAssertEqual(range.lowerBound, 0.0) - // Assert that the range upper bound is the total weight - XCTAssertEqual(range.upperBound, expectedTotalWeight) - return 0.0 - } + randomizer: randomizer ) // WHEN - experimentCohortsManager = ExperimentCohortsManager( - store: mockStore, - randomizer: { range in Double.random(in: range)} - ) - let result = experimentCohortsManager.assignCohort(to: subfeature) + let result = experimentCohortsManager.cohort(for: experiment, assignIfEnabled: true) // THEN - XCTAssertEqual(result, "Cohort1") - XCTAssertEqual(cohorts[0].name, mockStore.experiments?[subfeature.subfeatureID]?.cohort) + XCTAssertEqual(result.cohortID, experimentData3.cohort) + XCTAssertTrue(result.didAttemptAssignment) } - } class MockExperimentDataStore: ExperimentsDataStoring { diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift index 0466155b0..8816daec2 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/ExperimentsDataStoreTests.swift @@ -47,8 +47,8 @@ final class ExperimentsDataStoreTests: XCTestCase { func testExperimentsGetReturnsDecodedExperiments() { // GIVEN - let experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: Date()) - let experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: Date()) + let experimentData1 = ExperimentData(parentID: "parent", cohort: "TestCohort1", enrollmentDate: Date()) + let experimentData2 = ExperimentData(parentID: "parent", cohort: "TestCohort2", enrollmentDate: Date()) let experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] let encoder = JSONEncoder() @@ -71,8 +71,8 @@ final class ExperimentsDataStoreTests: XCTestCase { func testExperimentsSetEncodesAndStoresData() throws { // GIVEN - let experimentData1 = ExperimentData(cohort: "TestCohort1", enrollmentDate: Date()) - let experimentData2 = ExperimentData(cohort: "TestCohort2", enrollmentDate: Date()) + let experimentData1 = ExperimentData(parentID: "parent", cohort: "TestCohort1", enrollmentDate: Date()) + let experimentData2 = ExperimentData(parentID: "parent2", cohort: "TestCohort2", enrollmentDate: Date()) let experiments = [subfeatureName1: experimentData1, subfeatureName2: experimentData2] // WHEN @@ -86,8 +86,10 @@ final class ExperimentsDataStoreTests: XCTestCase { let timeDifference1 = abs(experimentData1.enrollmentDate.timeIntervalSince(decodedExperiments?[subfeatureName1]?.enrollmentDate ?? Date())) let timeDifference2 = abs(experimentData2.enrollmentDate.timeIntervalSince(decodedExperiments?[subfeatureName2]?.enrollmentDate ?? Date())) XCTAssertEqual(decodedExperiments?[subfeatureName1]?.cohort, experimentData1.cohort) + XCTAssertEqual(decodedExperiments?[subfeatureName1]?.parentID, experimentData1.parentID) XCTAssertLessThanOrEqual(timeDifference1, 1.0) XCTAssertEqual(decodedExperiments?[subfeatureName2]?.cohort, experimentData2.cohort) + XCTAssertEqual(decodedExperiments?[subfeatureName2]?.parentID, experimentData2.parentID) XCTAssertLessThanOrEqual(timeDifference2, 1.0) } } diff --git a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift index fac814b1c..08ecbec85 100644 --- a/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift +++ b/Tests/BrowserServicesKitTests/PrivacyConfig/PrivacyConfigurationDataTests.swift @@ -68,6 +68,9 @@ class PrivacyConfigurationDataTests: XCTestCase { XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?.count, 3) XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?[0].name, "myExperimentControl") XCTAssertEqual(subfeatures["enabledSubfeature"]?.cohorts?[0].weight, 1) + XCTAssertEqual(subfeatures["enabledSubfeature"]?.targets?[0].localeCountry, "US") + XCTAssertEqual(subfeatures["enabledSubfeature"]?.targets?[0].localeLanguage, "fr") + XCTAssertEqual(subfeatures["enabledSubfeature"]?.settings, "{\"foo\":\"foo\\/value\",\"bar\":\"bar\\/value\"}") XCTAssertEqual(subfeatures["internalSubfeature"]?.state, "internal") } else { XCTFail("Could not parse subfeatures") diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json b/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json index 3fd5be5a5..735c3914f 100644 --- a/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json +++ b/Tests/BrowserServicesKitTests/Resources/privacy-config-example.json @@ -171,6 +171,16 @@ }, "enabledSubfeature": { "state": "enabled", + "targets": [ + { + "localeCountry": "US", + "localeLanguage": "fr" + } + ], + "settings": { + "foo": "foo/value", + "bar": "bar/value" + }, "description": "A description of the sub-feature", "cohorts": [ { diff --git a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift index a85dadc1e..0334f4f7a 100644 --- a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift +++ b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift @@ -221,11 +221,11 @@ class MockPrivacyConfiguration: PrivacyConfiguration { var isSubfeatureEnabledCheck: ((any PrivacySubfeature) -> Bool)? - func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { + func isSubfeatureEnabled(_ subfeature: any BrowserServicesKit.PrivacySubfeature, cohortID: BrowserServicesKit.CohortID?, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> Bool { isSubfeatureEnabledCheck?(subfeature) ?? false } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any BrowserServicesKit.PrivacySubfeature, cohortID: BrowserServicesKit.CohortID?, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { if isSubfeatureEnabledCheck?(subfeature) == true { return .enabled } diff --git a/Tests/DDGSyncTests/Mocks/Mocks.swift b/Tests/DDGSyncTests/Mocks/Mocks.swift index 013123c6d..b65b8abdb 100644 --- a/Tests/DDGSyncTests/Mocks/Mocks.swift +++ b/Tests/DDGSyncTests/Mocks/Mocks.swift @@ -169,15 +169,11 @@ class MockPrivacyConfiguration: PrivacyConfiguration { return .enabled } - func isSubfeatureEnabled( - _ subfeature: any PrivacySubfeature, - versionProvider: AppVersionProvider, - randomizer: (Range) -> Double - ) -> Bool { + func isSubfeatureEnabled(_ subfeature: any BrowserServicesKit.PrivacySubfeature, cohortID: BrowserServicesKit.CohortID?, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> Bool { true } - func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + func stateFor(_ subfeature: any BrowserServicesKit.PrivacySubfeature, cohortID: BrowserServicesKit.CohortID?, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { return .enabled }