/* 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" ); /** * A class that mocks the IP Protection panel. */ class FakeIPProtectionPanelElement { constructor() { this.state = { isSignedOut: true, isProtectionEnabled: false, }; this.isConnected = false; this.ownerDocument = { removeEventListener() { /* NOOP */ }, }; } requestUpdate() { /* NOOP */ } remove() { /* NOOP */ } closest() { return { state: "open", }; } } 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.panel = fakeElement; 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.panel = fakeElement; 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 IPProtectionService ready state event updates the state. */ add_task(async function test_IPProtectionPanel_signedIn() { let sandbox = sinon.createSandbox(); sandbox.stub(IPPSignInWatcher, "isSignedIn").get(() => true); sandbox .stub(IPPEnrollAndEntitleManager, "isEnrolledAndEntitled") .get(() => true); sandbox.stub(IPPEnrollAndEntitleManager, "isLinkedToGuardian").resolves(true); sandbox.stub(IPProtectionService.guardian, "fetchUserInfo").resolves({ status: 200, error: null, entitlement: createTestEntitlement({ subscribed: true }), }); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.panel = fakeElement; fakeElement.isConnected = true; let signedInEventPromise = waitForEvent( IPProtectionService, "IPProtectionService:StateChanged", () => IPProtectionService.state === IPProtectionStates.READY ); IPProtectionService.updateState(); await signedInEventPromise; Assert.equal( ipProtectionPanel.state.isSignedOut, false, "isSignedOut should be false in the IPProtectionPanel state" ); Assert.equal( fakeElement.state.isSignedOut, false, "isSignedOut should be false in the fake elements state" ); sandbox.restore(); }); /** * Tests that IPProtectionService unauthenticated state event updates the state. */ add_task(async function test_IPProtectionPanel_signedOut() { let sandbox = sinon.createSandbox(); sandbox.stub(IPPSignInWatcher, "isSignedIn").get(() => false); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.panel = fakeElement; 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.isSignedOut, true, "isSignedOut should be true in the IPProtectionPanel state" ); Assert.equal( fakeElement.state.isSignedOut, true, "isSignedOut should be true in the fake elements state" ); sandbox.restore(); }); /** * 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.panel = fakeElement; fakeElement.isConnected = true; let sandbox = sinon.createSandbox(); sandbox.stub(IPPSignInWatcher, "isSignedIn").get(() => true); sandbox .stub(IPPEnrollAndEntitleManager, "isEnrolledAndEntitled") .get(() => true); sandbox.stub(IPPEnrollAndEntitleManager, "isLinkedToGuardian").resolves(true); sandbox.stub(IPProtectionService.guardian, "fetchUserInfo").resolves({ status: 200, error: null, entitlement: createTestEntitlement({ subscribed: true }), }); sandbox.stub(IPProtectionService.guardian, "fetchProxyPass").resolves({ status: 200, error: undefined, pass: new ProxyPass(createProxyPassToken()), usage: new ProxyUsage( "5368709120", "4294967296", "2026-02-01T00:00:00.000Z" ), }); sandbox .stub(IPProtectionService.guardian, "enrollWithFxa") .resolves({ ok: true }); 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" ); sandbox.restore(); }); /** * Tests that egress location preference changes update the state. */ add_task(async function test_IPProtectionPanel_egressLocation_pref() { let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.panel = fakeElement; fakeElement.isConnected = true; const expectedLocation = { name: "United States", code: "us", }; Services.prefs.setBoolPref( "browser.ipProtection.egressLocationEnabled", true ); Assert.deepEqual( ipProtectionPanel.state.location, expectedLocation, "location should be set when preference is true" ); Assert.deepEqual( fakeElement.state.location, expectedLocation, "location should be set on the fake element when preference is true" ); Services.prefs.setBoolPref( "browser.ipProtection.egressLocationEnabled", false ); Assert.ok( !ipProtectionPanel.state.location, "location should be null when preference is false" ); Assert.ok( !fakeElement.state.location, "location should be null on the fake element when preference is false" ); ipProtectionPanel.uninit(); Services.prefs.clearUserPref("browser.ipProtection.egressLocationEnabled"); }); /** * 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() { let sandbox = sinon.createSandbox(); setupStubs(sandbox); let ipProtectionPanel = new IPProtectionPanel(); let fakeElement = new FakeIPProtectionPanelElement(); ipProtectionPanel.panel = fakeElement; 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"); sandbox.restore(); }); 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(); 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.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(); 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.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(); 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.fog.testResetFOG(); });