/* Any copyright is dedicated to the Public Domain. https://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { IPProtectionPanel } = ChromeUtils.importESModule( "moz-src:///browser/components/ipprotection/IPProtectionPanel.sys.mjs" ); const { IPProtectionServerlist } = ChromeUtils.importESModule( "moz-src:///toolkit/components/ipprotection/IPProtectionServerlist.sys.mjs" ); /** * A class that mocks the IP Protection panel. */ class FakeIPProtectionPanelElement { constructor() { this.state = { isProtectionEnabled: false, }; this.isConnected = false; this.ownerDocument = { removeEventListener() { /* NOOP */ }, }; } requestUpdate() { /* NOOP */ } remove() { /* NOOP */ } } /** * A class that mocks the IP Protection panel. */ class FakeIPProtectionPanelView { constructor() { this.state = "open"; this.ownerDocument = { removeEventListener() { /* NOOP */ }, }; } hidePopup() { /* NOOP */ } } add_setup(async function () { // FxAccountsStorage.sys.mjs requires a profile directory. do_get_profile(); await putServerInRemoteSettings(); await IPProtectionService.init(); registerCleanupFunction(async () => { IPProtectionService.uninit(); }); }); /** * Tests that we can set a state and pass it to a fake element. */ add_task(async function test_setState() { let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); ipProtectionPanel.state = {}; fakeElement.state = {}; ipProtectionPanel.setState({ foo: "bar", }); Assert.deepEqual( ipProtectionPanel.state, { foo: "bar" }, "The state should be set on the IPProtectionPanel instance" ); Assert.deepEqual( fakeElement.state, {}, "The state should not be set on the fake element, as it is not connected" ); fakeElement.isConnected = true; ipProtectionPanel.setState({ isFoo: true, }); Assert.deepEqual( ipProtectionPanel.state, { foo: "bar", isFoo: true }, "The state should be set on the IPProtectionPanel instance" ); Assert.deepEqual( fakeElement.state, { foo: "bar", isFoo: true }, "The state should be set on the fake element" ); }); /** * Tests that the whole state will be updated when calling updateState directly. */ add_task(async function test_updateState() { let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); ipProtectionPanel.state = {}; fakeElement.state = {}; ipProtectionPanel.setState({ foo: "bar", }); Assert.deepEqual( fakeElement.state, {}, "The state should not be set on the fake element, as it is not connected" ); fakeElement.isConnected = true; ipProtectionPanel.updateState(); Assert.deepEqual( fakeElement.state, { foo: "bar" }, "The state should be set on the fake element" ); }); /** * Tests that we can set a state on multiple fake elements. */ add_task(async function test_updateComponentState() { let ipProtectionPanel = new IPProtectionPanel(); let fakeElementA = new FakeIPProtectionPanelElement(); let fakeElementB = new FakeIPProtectionPanelElement(); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); ipProtectionPanel.state = { foo: "bar", }; fakeElementA.state = {}; fakeElementA.isConnected = true; fakeElementB.state = {}; fakeElementB.isConnected = true; ipProtectionPanel.updateComponentState(fakeElementA); Assert.ok( ipProtectionPanel.components.has(fakeElementA), "The fake element A should be in the components set" ); Assert.deepEqual( fakeElementA.state, { foo: "bar" }, "The state should be set on the fake element A" ); Assert.deepEqual( fakeElementB.state, {}, "The state should not be set on the fake element B" ); ipProtectionPanel.updateComponentState(fakeElementB); Assert.ok( ipProtectionPanel.components.has(fakeElementB), "The fake element B should be in the components set" ); Assert.deepEqual( fakeElementB.state, { foo: "bar" }, "The state should be set on the fake element B" ); // Updating the state now should update both elements. ipProtectionPanel.setState({ isFoo: true, }); Assert.deepEqual( fakeElementA.state, { foo: "bar", isFoo: true }, "The state should be set on the fake element A" ); Assert.deepEqual( fakeElementB.state, { foo: "bar", isFoo: true }, "The state should be set on the fake element B" ); }); /** * Tests that IPProtectionService ready state event updates the state. */ add_task(async function test_IPProtectionPanel_signedIn() { IPPDummyAuthProvider.simulateSignIn(true); IPPDummyAuthProvider.setEntitlement(createTestEntitlement(), { silent: true, }); IPPDummyAuthProvider.setGetEntitlementResponse({ entitlement: createTestEntitlement(), }); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); fakeElement.isConnected = true; let signedInEventPromise = waitForEvent( IPProtectionService, "IPProtectionService:StateChanged", () => IPProtectionService.state === IPProtectionStates.READY ); IPProtectionService.updateState(); await signedInEventPromise; Assert.equal( ipProtectionPanel.state.unauthenticated, false, "unauthenticated should be false in the IPProtectionPanel state" ); Assert.equal( fakeElement.state.unauthenticated, false, "unauthenticated should be false in the fake elements state" ); }); /** * Tests that IPProtectionService unauthenticated state event updates the state. */ add_task(async function test_IPProtectionPanel_signedOut() { IPPDummyAuthProvider.simulateSignIn(false); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); fakeElement.isConnected = true; IPProtectionService.setState(IPProtectionStates.READY); let signedOutEventPromise = waitForEvent( IPProtectionService, "IPProtectionService:StateChanged", () => IPProtectionService.state === IPProtectionStates.UNAUTHENTICATED ); IPProtectionService.updateState(); await signedOutEventPromise; Assert.equal( ipProtectionPanel.state.unauthenticated, true, "unauthenticated should be true in the IPProtectionPanel state" ); Assert.equal( fakeElement.state.unauthenticated, true, "unauthenticated should be true in the fake elements state" ); }); /** * Tests that start and stopping the IPProtectionService updates the state. */ add_task(async function test_IPProtectionPanel_started_stopped() { let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); fakeElement.isConnected = true; IPPDummyAuthProvider.simulateSignIn(true); IPPDummyAuthProvider.setEntitlement(createTestEntitlement(), { silent: true, }); IPPDummyAuthProvider.setGetEntitlementResponse({ entitlement: createTestEntitlement(), }); IPPDummyAuthProvider.setProxyPass({ status: 200, error: undefined, pass: new ProxyPass(createProxyPassToken()), usage: new ProxyUsage( "5368709120", "4294967296", "2026-02-01T00:00:00.000Z" ), }); IPProtectionService.updateState(); Assert.equal( IPProtectionService.state, IPProtectionStates.READY, "IP Protection service should be in READY state before starting" ); let startedEventPromise = waitForEvent( IPPProxyManager, "IPPProxyManager:StateChanged", () => IPPProxyManager.state === IPPProxyStates.ACTIVE ); IPPProxyManager.start(); await startedEventPromise; Assert.equal( ipProtectionPanel.state.isProtectionEnabled, true, "isProtectionEnabled should be true in the IPProtectionPanel state" ); Assert.equal( fakeElement.state.isProtectionEnabled, true, "isProtectionEnabled should be true in the fake elements state" ); let stoppedEventPromise = waitForEvent( IPPProxyManager, "IPPProxyManager:StateChanged", () => IPPProxyManager.state !== IPPProxyStates.ACTIVE ); await IPPProxyManager.stop(); await stoppedEventPromise; Assert.equal( ipProtectionPanel.state.isProtectionEnabled, false, "isProtectionEnabled should be false in the IPProtectionPanel state" ); Assert.equal( fakeElement.state.isProtectionEnabled, false, "isProtectionEnabled should be false in the fake elements state" ); }); /** * Tests that locationsList is populated from IPProtectionServerlist and * kept in sync with IPProtectionServerlist:ListChanged events. */ add_task(async function test_IPProtectionPanel_locationsList() { await IPProtectionServerlist.maybeFetchList(true); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); fakeElement.isConnected = true; Assert.deepEqual( ipProtectionPanel.state.locationsList, IPProtectionServerlist.countries, "locationsList should be set to IPProtectionServerlist.countries at construction" ); Assert.ok( ipProtectionPanel.state.locationsList.some(c => c.code === "US"), "locationsList should include the seeded US country" ); IPProtectionServerlist.dispatchEvent( new Event("IPProtectionServerlist:ListChanged") ); Assert.deepEqual( ipProtectionPanel.state.locationsList, IPProtectionServerlist.countries, "locationsList should be refreshed when ListChanged fires" ); Assert.deepEqual( fakeElement.state.locationsList, IPProtectionServerlist.countries, "locationsList should propagate to the connected element" ); ipProtectionPanel.uninit(); }); /** * Tests that UsageChanged events with BigInt(0) remaining bandwidth * are processed correctly (not treated as falsy and skipped). * * Regression test: BigInt(0) is falsy in JavaScript, so a guard like * `!usage.remaining` would incorrectly bail out when remaining is exactly 0. */ add_task(async function test_IPProtectionPanel_usage_zero_remaining() { setupStubs(); Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.components.add(fakeElement); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); fakeElement.isConnected = true; Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold"); // Create a usage object with remaining = 0 (BigInt) const usage = new ProxyUsage("5368709120", "0", "3026-02-01T00:00:00.000Z"); Assert.equal(usage.remaining, BigInt(0), "remaining should be BigInt(0)"); // Dispatch a UsageChanged event with zero remaining bandwidth IPPProxyManager.dispatchEvent( new CustomEvent("IPPProxyManager:UsageChanged", { bubbles: true, composed: true, detail: { usage }, }) ); // With 0 bytes remaining out of 5GB, remainingPercent = 0. // This is <= THIRD_THRESHOLD (0.1), so threshold should be set to 90. const threshold = Services.prefs.getIntPref( "browser.ipProtection.bandwidthThreshold", 0 ); Assert.equal( threshold, 100, "bandwidthThreshold pref should be 100 when remaining bandwidth is zero" ); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold"); Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled"); }); /** * Tests that opening the panel while paused re-checks usage. */ add_task(async function test_showing_refreshes_usage_when_paused() { let ipProtectionPanel = new IPProtectionPanel(); ipProtectionPanel.panel = new FakeIPProtectionPanelView(); let refreshUsageStub = sinon.stub(IPPProxyManager, "refreshUsage").resolves(); ipProtectionPanel.state.paused = false; ipProtectionPanel.showing(ipProtectionPanel.panel); Assert.ok( refreshUsageStub.notCalled, "refreshUsage should not be called when opening the panel while not paused" ); ipProtectionPanel.state.paused = true; ipProtectionPanel.showing(ipProtectionPanel.panel); Assert.ok( refreshUsageStub.calledOnce, "refreshUsage should be called when opening the panel while paused" ); refreshUsageStub.restore(); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.everOpenedPanel"); Services.prefs.clearUserPref("browser.ipProtection.openedPanelWithLocation"); }); /** * Tests that showLocationButtonBadge is true when the dismissed pref is not set. */ add_task(async function test_location_badge_initial_state_pref_unset() { Services.prefs.clearUserPref( "browser.ipProtection.locationButtonBadgeDismissed" ); let ipProtectionPanel = new IPProtectionPanel(); Assert.equal( ipProtectionPanel.state.showLocationButtonBadge, true, "showLocationButtonBadge should be true when pref is not set" ); ipProtectionPanel.uninit(); }); /** * Tests that showLocationButtonBadge is false when the dismissed pref is set to true. */ add_task(async function test_location_badge_initial_state_pref_set() { Services.prefs.setBoolPref( "browser.ipProtection.locationButtonBadgeDismissed", true ); let ipProtectionPanel = new IPProtectionPanel(); Assert.equal( ipProtectionPanel.state.showLocationButtonBadge, false, "showLocationButtonBadge should be false when pref is set to true" ); ipProtectionPanel.uninit(); Services.prefs.clearUserPref( "browser.ipProtection.locationButtonBadgeDismissed" ); }); function dispatchUsageEvent(max, remaining) { IPPProxyManager.dispatchEvent( new CustomEvent("IPPProxyManager:UsageChanged", { bubbles: true, composed: true, detail: { usage: new ProxyUsage( String(max), String(remaining), "3026-03-01T00:00:00.000Z" ), }, }) ); } /** * Tests that bandwidth threshold telemetry events fire at 50%, 75%, and 90%. */ add_task(async function test_bandwidth_used_threshold_events() { Services.fog.initializeFOG(); Services.fog.testResetFOG(); Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true); let ipProtectionPanel = new IPProtectionPanel(); // 40% used (60% remaining) - no thresholds crossed dispatchUsageEvent(1000000, 600000); Assert.equal( Glean.ipprotection.bandwidthUsedThreshold.testGetValue(), null, "No threshold event should fire at 40% used" ); // 55% used (45% remaining) - crosses 50% dispatchUsageEvent(1000000, 450000); let events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal(events.length, 1, "One threshold event should fire at 55% used"); Assert.equal(events[0].extra.percentage, "50", "Should report 50% threshold"); // 80% used (20% remaining) - crosses 75% dispatchUsageEvent(1000000, 200000); events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal(events.length, 2, "Two threshold events total at 80% used"); Assert.equal(events[1].extra.percentage, "75", "Should report 75% threshold"); // 95% used (5% remaining) - crosses 90% dispatchUsageEvent(1000000, 50000); events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal(events.length, 3, "Three threshold events total at 95% used"); Assert.equal(events[2].extra.percentage, "90", "Should report 90% threshold"); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold"); Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled"); Services.fog.testResetFOG(); }); /** * Tests that threshold events are not re-fired within the same usage period. */ add_task(async function test_bandwidth_thresholds_not_repeated_same_period() { Services.fog.testResetFOG(); Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true); let ipProtectionPanel = new IPProtectionPanel(); // Cross 50% threshold dispatchUsageEvent(1000000, 400000); let events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal(events.length, 1, "One event after first call at 60% used"); // Same usage dispatched again - should not re-fire dispatchUsageEvent(1000000, 400000); events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal( events.length, 1, "No additional event when threshold already reported" ); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold"); Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled"); Services.fog.testResetFOG(); }); /** * Tests that thresholds reset when a new usage period begins. */ add_task(async function test_bandwidth_thresholds_reset_on_new_period() { Services.fog.testResetFOG(); Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true); let ipProtectionPanel = new IPProtectionPanel(); // Cross 50% in the current period dispatchUsageEvent(1000000, 400000); let events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal(events.length, 1, "One event in current period"); Assert.equal(events[0].extra.percentage, "50"); // Simulate a period reset by returning to full bandwidth (threshold drops to 0) dispatchUsageEvent(1000000, 1000000); Services.fog.testResetFOG(); // 50% should fire again since the threshold pref was reset to 0 dispatchUsageEvent(1000000, 400000); events = Glean.ipprotection.bandwidthUsedThreshold.testGetValue(); Assert.equal(events.length, 1, "50% fires again after period reset"); Assert.equal(events[0].extra.percentage, "50"); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold"); Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled"); Services.fog.testResetFOG(); }); /** * Tests that an unlimited UsageChanged event clears the bandwidth tracking * prefs and resets the bandwidthUsage state. */ add_task(async function test_bandwidth_unlimited_usage_clears_tracking() { Services.fog.initializeFOG(); Services.fog.testResetFOG(); Services.prefs.setBoolPref("browser.ipProtection.bandwidth.enabled", true); Services.prefs.setIntPref("browser.ipProtection.bandwidthThreshold", 75); Services.prefs.setStringPref( "browser.ipProtection.bandwidthResetDate", "3026-03-01T00:00:00.000Z" ); let ipProtectionPanel = new IPProtectionPanel(); ipProtectionPanel.setState({ bandwidthUsage: { max: 1000000, remaining: 200000, reset: null }, }); IPPProxyManager.dispatchEvent( new CustomEvent("IPPProxyManager:UsageChanged", { bubbles: true, composed: true, detail: { usage: new ProxyUsage(null, null, null, true) }, }) ); Assert.strictEqual( ipProtectionPanel.state.bandwidthUsage, null, "bandwidthUsage state should be reset for unlimited usage" ); Assert.ok( !Services.prefs.prefHasUserValue("browser.ipProtection.bandwidthThreshold"), "bandwidthThreshold pref should be cleared for unlimited usage" ); Assert.ok( !Services.prefs.prefHasUserValue("browser.ipProtection.bandwidthResetDate"), "bandwidthResetDate pref should be cleared for unlimited usage" ); Assert.equal( Glean.ipprotection.bandwidthUsedThreshold.testGetValue(), null, "No threshold telemetry should be recorded for unlimited usage" ); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.bandwidth.enabled"); Services.prefs.clearUserPref("browser.ipProtection.bandwidthThreshold"); Services.prefs.clearUserPref("browser.ipProtection.bandwidthResetDate"); Services.fog.testResetFOG(); });