tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 7addb3e109535c9af6873ff2d4f160e299f57038
parent 39384659753b236c9e233587b932bd90d02ce980
Author: Beth Rennie <beth@brennie.ca>
Date:   Mon, 27 Oct 2025 19:27:52 +0000

Bug 1972647 - Allow Firefox Labs to be enabled even if studies or telemetry are disabled r=mkaply,nimbus-reviewers,relud

Both Firefox Labs and one of either studies or telemetry must now be
disabled to fully disable Nimbus.

Differential Revision: https://phabricator.services.mozilla.com/D269920

Diffstat:
Mbrowser/components/enterprisepolicies/Policies.sys.mjs | 4++++
Mtoolkit/components/nimbus/ExperimentAPI.sys.mjs | 10+++++++++-
Mtoolkit/components/nimbus/lib/ExperimentManager.sys.mjs | 40++++++++++++++++++++++------------------
Mtoolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs | 33++++++++++++++++++++++++---------
Mtoolkit/components/nimbus/lib/Telemetry.sys.mjs | 1+
Mtoolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js | 16++++++++++------
Mtoolkit/components/nimbus/test/unit/test_policy.js | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
7 files changed, 185 insertions(+), 57 deletions(-)

diff --git a/browser/components/enterprisepolicies/Policies.sys.mjs b/browser/components/enterprisepolicies/Policies.sys.mjs @@ -2924,6 +2924,10 @@ export var Policies = { ); } if ("FirefoxLabs" in param) { + if (!param.FirefoxLabs) { + manager.disallowFeature("FirefoxLabs"); + } + PoliciesUtils.setDefaultPref( "browser.preferences.experimental", param.FirefoxLabs, diff --git a/toolkit/components/nimbus/ExperimentAPI.sys.mjs b/toolkit/components/nimbus/ExperimentAPI.sys.mjs @@ -370,6 +370,14 @@ export const ExperimentAPI = new (class { this.#initialized = false; } + get enabled() { + return this.studiesEnabled || this.labsEnabled; + } + + get labsEnabled() { + return Services.policies.isAllowed("FirefoxLabs"); + } + get studiesEnabled() { return ( this.#prefValues.studiesEnabled && @@ -441,7 +449,7 @@ export const ExperimentAPI = new (class { } if (!this.studiesEnabled) { - await this.manager._handleStudiesOptOut(); + this.manager._handleStudiesOptOut(); } await this._rsLoader.onEnabledPrefChange(); diff --git a/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs @@ -308,10 +308,6 @@ export class ExperimentManager { return; } - if (result.ok && recipe.isFirefoxLabsOptIn) { - this.optInRecipes.push(recipe); - } - if (!result.ok) { lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, @@ -322,8 +318,15 @@ export class ExperimentManager { return; } + // Unenrollment due to studies becoming disabled is handled in + // `_handleStudiesOptOut`. + if (result.status === lazy.MatchStatus.DISABLED) { + return; + } + if (recipe.isFirefoxLabsOptIn) { // We do not enroll directly into Firefox Labs opt-ins. + this.optInRecipes.push(recipe); return; } @@ -749,8 +752,17 @@ export class ExperimentManager { const { EnrollmentStatus, EnrollmentStatusReason, UnenrollReason } = lazy.NimbusTelemetry; - if (result.ok && recipe?.isFirefoxLabsOptIn) { - this.optInRecipes.push(recipe); + if (result.ok) { + // Unenrollment due to studies becoming disabled is handled in + // `_handleStudiesOptOut`. Firefox Labs can only be disabled by policy and + // thus its enabled state cannot change at runtime. + if (result.status === lazy.MatchStatus.DISABLED) { + return false; + } + + if (recipe?.isFirefoxLabsOptIn) { + this.optInRecipes.push(recipe); + } } if (enrollment.active) { @@ -925,8 +937,10 @@ export class ExperimentManager { /** * Unenroll from all active studies if user opts out. */ - async _handleStudiesOptOut() { - for (const enrollment of this.store.getAllActiveExperiments()) { + _handleStudiesOptOut() { + for (const enrollment of this.store + .getAll() + .filter(e => e.active && !e.isFirefoxLabsOptIn)) { this._unenroll( enrollment, UnenrollmentCause.fromReason( @@ -934,16 +948,6 @@ export class ExperimentManager { ) ); } - for (const enrollment of this.store.getAllActiveRollouts()) { - this._unenroll( - enrollment, - UnenrollmentCause.fromReason( - lazy.NimbusTelemetry.UnenrollReason.STUDIES_OPT_OUT - ) - ); - } - - this.optInRecipes = []; } /** diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs @@ -114,6 +114,7 @@ export const MatchStatus = Object.freeze({ TARGETING_ONLY: "TARGETING_ONLY", TARGETING_AND_BUCKETING: "TARGETING_AND_BUCKETING", UNENROLLED_IN_ANOTHER_PROFILE: "UNENROLLED_IN_ANOTHER_PROFILE", + DISABLED: "DISABLED", }); export const CheckRecipeResult = { @@ -261,9 +262,9 @@ export class RemoteSettingsExperimentLoader { return; } - if (!lazy.ExperimentAPI.studiesEnabled) { + if (!lazy.ExperimentAPI.enabled) { lazy.log.debug( - "Not enabling RemoteSettingsExperimentLoader: studies disabled" + "Not enabling RemoteSettingsExperimentLoader: Nimbus disabled" ); return; } @@ -431,6 +432,8 @@ export class RemoteSettingsExperimentLoader { recipeValidator, { validationEnabled, + labsEnabled: lazy.ExperimentAPI.labsEnabled, + studiesEnabled: lazy.ExperimentAPI.studiesEnabled, shouldCheckTargeting: true, unenrolledExperimentSlugs, } @@ -768,15 +771,15 @@ export class RemoteSettingsExperimentLoader { } /** - * Handles feature status based on STUDIES_OPT_OUT_PREF. - * - * Changing this pref to false will turn off any recipe fetching and - * processing. + * Disable the RemoteSettingsExperimentLoader if Nimbus has become disabled + * and vice versa. */ async onEnabledPrefChange() { - if (this._enabled && !lazy.ExperimentAPI.studiesEnabled) { + const nimbusEnabled = lazy.ExperimentAPI.enabled; + + if (this._enabled && !nimbusEnabled) { this.disable(); - } else if (!this._enabled && lazy.ExperimentAPI.studiesEnabled) { + } else if (!this._enabled && nimbusEnabled) { // If the feature pref is turned on then turn on recipe processing. // If the opt in pref is turned on then turn on recipe processing only if // the feature pref is also enabled. @@ -823,7 +826,7 @@ export class RemoteSettingsExperimentLoader { * immediately. */ finishedUpdating() { - if (!lazy.ExperimentAPI.studiesEnabled || !this._enabled) { + if (!lazy.ExperimentAPI.enabled || !this._enabled) { return Promise.resolve(); } @@ -912,12 +915,17 @@ export class EnrollmentsContext { validationEnabled = true, shouldCheckTargeting = true, unenrolledExperimentSlugs, + studiesEnabled = true, + labsEnabled = true, } = {} ) { this.manager = manager; this.recipeValidator = recipeValidator; this.validationEnabled = validationEnabled; + this.studiesEnabled = studiesEnabled; + this.labsEnabled = labsEnabled; + this.validatorCache = {}; this.shouldCheckTargeting = shouldCheckTargeting; this.unenrolledExperimentSlugs = unenrolledExperimentSlugs; @@ -951,6 +959,13 @@ export class EnrollmentsContext { } } + if ( + (recipe.isFirefoxLabsOptIn && !this.labsEnabled) || + (!recipe.isFirefoxLabsOptIn && !this.studiesEnabled) + ) { + return CheckRecipeResult.Ok(MatchStatus.DISABLED); + } + // We don't include missing features here because if validation is enabled we report those errors later. const unsupportedFeatureIds = recipe.featureIds.filter( featureId => diff --git a/toolkit/components/nimbus/lib/Telemetry.sys.mjs b/toolkit/components/nimbus/lib/Telemetry.sys.mjs @@ -81,6 +81,7 @@ const UnenrollReason = Object.freeze({ CHANGED_PREF: "changed-pref", FORCE_ENROLLMENT: "force-enrollment", INDIVIDUAL_OPT_OUT: "individual-opt-out", + LABS_DIABLED: "labs-disabled", LABS_OPT_OUT: "labs-opt-out", PREF_FLIPS_CONFLICT: "prefFlips-conflict", PREF_FLIPS_FAILED: "prefFlips-failed", diff --git a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js @@ -63,17 +63,21 @@ add_task(async function test_init_with_opt_in() { await initExperimentAPI(); - equal( + Assert.equal( loader.setTimer.callCount, - 0, - `should not initialize if ${STUDIES_OPT_OUT_PREF} pref is false` + 1, + `should initialize even if ${STUDIES_OPT_OUT_PREF} pref is false` ); - Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); - Assert.ok(loader.setTimer.calledOnce, "should call .setTimer"); - Assert.ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); + Assert.equal( + loader.updateRecipes.callCount, + 1, + "should call updateRecipes()" + ); await cleanup(); + + Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); }); add_task(async function test_updateRecipes() { diff --git a/toolkit/components/nimbus/test/unit/test_policy.js b/toolkit/components/nimbus/test/unit/test_policy.js @@ -8,18 +8,43 @@ const { EnterprisePolicyTesting } = ChromeUtils.importESModule( "resource://testing-common/EnterprisePolicyTesting.sys.mjs" ); +const RECIPES = [ + NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", { + featureId: "no-feature-firefox-desktop", + }), + NimbusTestUtils.factories.recipe.withFeatureConfig( + "rollout", + { featureId: "no-feature-firefox-desktop" }, + { isRollout: true } + ), + NimbusTestUtils.factories.recipe.withFeatureConfig( + "optin", + { featureId: "no-feature-firefox-desktop" }, + { + isRollout: true, + isFirefoxLabsOptIn: true, + firefoxLabsTitle: "title", + firefoxLabsDescription: "description", + firefoxLabsGroup: "group", + requiresRestart: false, + } + ), +]; + add_setup(function setup() { // Instantiate the enterprise policy service. void Cc["@mozilla.org/enterprisepolicies;1"].getService(Ci.nsIObserver); }); -add_task(async function testPolicyDisablesNimbus() { +async function doTest({ + policies, + labsEnabled, + studiesEnabled, + expectedEnrollments, + expectedOptIns, +}) { info("Enabling policy"); - await EnterprisePolicyTesting.setupPolicyEngineWithJson({ - policies: { - DisableFirefoxStudies: true, - }, - }); + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ policies }); info("Is policy engine active?"); Assert.equal( @@ -28,28 +53,95 @@ add_task(async function testPolicyDisablesNimbus() { "Policy engine is active" ); - const loader = NimbusTestUtils.stubs.rsLoader(); - const manager = loader.manager; - await manager.store.init(); - await manager.onStartup(); + const { initExperimentAPI, cleanup, loader } = + await NimbusTestUtils.setupTest({ + init: false, + experiments: RECIPES, + }); - Assert.ok(!manager.studiesEnabled, "ExperimentManager is disabled"); + sinon.spy(loader, "updateRecipes"); + sinon.spy(loader, "setTimer"); - const setTimerStub = sinon.stub(loader, "setTimer"); - const updateRecipes = sinon.stub(loader, "updateRecipes"); + await initExperimentAPI(); - await loader.enable(); + Assert.equal( + ExperimentAPI.studiesEnabled, + studiesEnabled, + "Studies are enabled" + ); + Assert.equal( + ExperimentAPI.labsEnabled, + labsEnabled, + "FirefoxLabs is enabled" + ); + + Assert.equal( + loader._enabled, + studiesEnabled || labsEnabled, + "RemoteSettingsExperimentLoader initialized" + ); + + Assert.equal( + loader.setTimer.called, + studiesEnabled || labsEnabled, + "RemoteSettingsExperimentLoader polling for recipes" + ); - Assert.ok( - !loader._initialized, - "RemoteSettingsExperimentLoader not initailized" + Assert.equal( + loader.updateRecipes.called, + studiesEnabled || labsEnabled, + "RemoteSettingsExperimentLoader polling for recipes" ); - Assert.ok( - setTimerStub.notCalled, - "RemoteSettingsExperimentLoader not polling for recipes" + + Assert.deepEqual( + ExperimentAPI.manager.store + .getAll() + .filter(e => e.active) + .map(e => e.slug) + .sort(), + expectedEnrollments.sort(), + "Should have expected enrollments" ); - Assert.ok( - updateRecipes.notCalled, - "RemoteSettingsExperimentLoader not updating recipes after startup" + + Assert.deepEqual( + ExperimentAPI.manager.optInRecipes.map(e => e.slug).sort(), + expectedOptIns, + "Should have expected available opt-ins" ); + + await NimbusTestUtils.cleanupManager(expectedEnrollments); + await cleanup(); +} + +add_task(async function testDisableStudiesPolicy() { + await doTest({ + policies: { DisableFirefoxStudies: true }, + labsEnabled: true, + studiesEnabled: false, + expectedEnrollments: [], + expectedOptIns: ["optin"], + }); +}); + +add_task(async function testDisableLabsPolicy() { + await doTest({ + policies: { UserMessaging: { FirefoxLabs: false } }, + labsEnabled: false, + studiesEnabled: true, + expectedEnrollments: ["experiment", "rollout"], + expectedOptIns: [], + }); +}); + +add_task(async function testNimbusDisabled() { + await doTest({ + policies: { + DisableFirefoxStudies: true, + UserMessaging: { FirefoxLabs: false }, + }, + labsEnabled: false, + studiesEnabled: false, + expectedEnrollments: [], + expectedOptIns: [], + }); });