Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { IPPLifecycleHelper } = ChromeUtils.importESModule(
"moz-src:///toolkit/components/ipprotection/IPPLifecycleHelper.sys.mjs"
);
const SLEEP_TOPIC = "sleep_notification";
const WAKE_TOPIC = "wake_notification";
add_setup(async function () {
await putServerInRemoteSettings();
});
/**
* Brings the proxy to the ACTIVE state, at which point the lifecycle helper is
* observing sleep/wake.
*/
async function startActiveProxy() {
const readyEvent = waitForEvent(
IPProtectionService,
"IPProtectionService:StateChanged",
() => IPProtectionService.state === IPProtectionStates.READY
);
IPProtectionService.init();
await readyEvent;
const activeEvent = waitForEvent(
IPPProxyManager,
"IPPProxyManager:StateChanged",
() => IPPProxyManager.state === IPPProxyStates.ACTIVE
);
await IPPProxyManager.start();
await activeEvent;
}
/**
* On sleep, the channel filter is suspended (proxyInfo cleared).
*/
add_task(async function test_sleep_suspends_connection() {
setupStubs({ validProxyPass: true });
await startActiveProxy();
const channelFilter = IPPProxyManager.channelFilter();
Assert.ok(channelFilter.proxyInfo, "Connection has proxyInfo while active");
Assert.ok(IPPProxyManager.isolationKey, "Connection has an isolation key");
Services.obs.notifyObservers(null, SLEEP_TOPIC);
Assert.equal(
channelFilter.proxyInfo,
null,
"Channel filter is suspended after sleep"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
});
/**
* On wake with a still-good pass, the saved isolation key is re-injected
* (same key, no rotation).
*/
add_task(async function test_wake_with_valid_pass_resumes_same_key() {
let sandbox = sinon.createSandbox();
setupStubs({ validProxyPass: true });
await startActiveProxy();
const channelFilter = IPPProxyManager.channelFilter();
const savedKey = IPPProxyManager.isolationKey;
const rotateSpy = sandbox.spy(IPPProxyManager, "rotateProxyPass");
Services.obs.notifyObservers(null, SLEEP_TOPIC);
Assert.equal(channelFilter.proxyInfo, null, "Suspended after sleep");
Services.obs.notifyObservers(null, WAKE_TOPIC);
Assert.ok(channelFilter.proxyInfo, "Connection resumed after wake");
Assert.equal(
IPPProxyManager.isolationKey,
savedKey,
"Resuming reuses the isolation key captured before sleep"
);
Assert.ok(rotateSpy.notCalled, "A valid pass should not trigger a rotation");
await IPPProxyManager.stop();
IPProtectionService.uninit();
sandbox.restore();
});
/**
* On wake with an expired pass, the helper triggers a rotation, which yields a
* fresh isolation key.
*/
add_task(async function test_wake_with_expired_pass_rotates() {
let sandbox = sinon.createSandbox();
setupStubs({ validProxyPass: true });
await startActiveProxy();
// Rotate to an expired pass while staying active so canResume is false on the
// next wake.
setupStubs({ validProxyPass: false });
await IPPProxyManager.rotateProxyPass();
Assert.ok(
!IPPProxyManager.hasValidProxyPass,
"Pass is expired after rotating to an expired pass"
);
const savedKey = IPPProxyManager.isolationKey;
const rotateSpy = sandbox.spy(IPPProxyManager, "rotateProxyPass");
Services.obs.notifyObservers(null, SLEEP_TOPIC);
// The wake rotation should fetch a fresh, valid pass.
setupStubs({ validProxyPass: true });
Services.obs.notifyObservers(null, WAKE_TOPIC);
Assert.ok(rotateSpy.called, "An expired pass should trigger a rotation");
await rotateSpy.returnValues[0];
Assert.ok(channelFilterProxyInfoPresent(), "Connection has proxyInfo again");
Assert.notEqual(
IPPProxyManager.isolationKey,
savedKey,
"Rotation produces a new isolation key"
);
Assert.equal(
IPPProxyManager.state,
IPPProxyStates.ACTIVE,
"Proxy stays active across a wake rotation"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
sandbox.restore();
});
/**
* On wake with a pass that is valid but already inside its rotation window, the
* helper rotates instead of resuming (validates the canResume threshold).
*/
add_task(async function test_wake_within_rotation_window_rotates() {
let sandbox = sinon.createSandbox();
setupStubs({ validProxyPass: true });
await startActiveProxy();
// Rotate to a pass that is still valid but due for rotation (expires in 30s,
// well within the 2 minute rotation window).
const now = Temporal.Now.instant();
const soonToExpire = new ProxyPass(
createProxyPassToken(now, now.add({ seconds: 30 }))
);
IPPDummyAuthProvider.setProxyPass({
status: 200,
error: undefined,
pass: soonToExpire,
usage: new ProxyUsage(
"5368709120",
"4294967296",
"3026-02-01T00:00:00.000Z"
),
});
await IPPProxyManager.rotateProxyPass();
Assert.ok(
IPPProxyManager.hasValidProxyPass,
"Pass is still valid before its expiry"
);
Assert.ok(
!IPPProxyManager.channelFilter().canResume,
"Pass within the rotation window cannot be resumed"
);
const savedKey = IPPProxyManager.isolationKey;
const rotateSpy = sandbox.spy(IPPProxyManager, "rotateProxyPass");
Services.obs.notifyObservers(null, SLEEP_TOPIC);
setupStubs({ validProxyPass: true });
Services.obs.notifyObservers(null, WAKE_TOPIC);
Assert.ok(
rotateSpy.called,
"A pass within the rotation window should trigger a rotation"
);
await rotateSpy.returnValues[0];
Assert.notEqual(
IPPProxyManager.isolationKey,
savedKey,
"Rotation produces a new isolation key"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
sandbox.restore();
});
/**
* Sleep/wake observers are only registered while the proxy is ACTIVE.
*/
add_task(async function test_observers_only_active_while_active() {
let sandbox = sinon.createSandbox();
setupStubs({ validProxyPass: true });
await startActiveProxy();
await IPPProxyManager.stop();
Assert.notEqual(
IPPProxyManager.state,
IPPProxyStates.ACTIVE,
"Proxy is no longer active after stop"
);
const observeSpy = sandbox.spy(IPPLifecycleHelper, "observe");
Services.obs.notifyObservers(null, SLEEP_TOPIC);
Services.obs.notifyObservers(null, WAKE_TOPIC);
Assert.ok(
observeSpy.notCalled,
"The helper stops observing power events once the proxy is inactive"
);
IPProtectionService.uninit();
sandbox.restore();
});
/**
* On Android the helper reacts to the app moving to the background/foreground
* the same way it reacts to sleep/wake on desktop: suspend on background, resume
* on foreground.
*/
add_task(async function test_android_background_foreground_cycle() {
setupStubs({ validProxyPass: true });
await startActiveProxy();
const channelFilter = IPPProxyManager.channelFilter();
const savedKey = IPPProxyManager.isolationKey;
Services.obs.notifyObservers(null, "application-background");
Assert.equal(
channelFilter.proxyInfo,
null,
"Channel filter is suspended when the app is backgrounded"
);
Services.obs.notifyObservers(null, "application-foreground");
Assert.ok(
channelFilter.proxyInfo,
"Connection resumed when the app is foregrounded"
);
Assert.equal(
IPPProxyManager.isolationKey,
savedKey,
"Resuming reuses the isolation key captured before backgrounding"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
});
/**
* isSuspended reflects the sleep/background state, tracking both the desktop
* sleep/wake and the Android background/foreground notifications.
*/
add_task(async function test_isSuspended_tracks_sleep_wake() {
setupStubs({ validProxyPass: true });
await startActiveProxy();
Assert.ok(
!IPPLifecycleHelper.isSuspended,
"Not suspended while awake and active"
);
Services.obs.notifyObservers(null, SLEEP_TOPIC);
Assert.ok(IPPLifecycleHelper.isSuspended, "Suspended after sleep");
Services.obs.notifyObservers(null, WAKE_TOPIC);
Assert.ok(!IPPLifecycleHelper.isSuspended, "Not suspended after wake");
Services.obs.notifyObservers(null, "application-background");
Assert.ok(
IPPLifecycleHelper.isSuspended,
"Suspended when the app is backgrounded"
);
Services.obs.notifyObservers(null, "application-foreground");
Assert.ok(
!IPPLifecycleHelper.isSuspended,
"Not suspended when the app is foregrounded"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
});
/**
* A successful rotation reschedules the next one for the freshly obtained pass,
* cancelling any earlier schedule. This keeps rotation going even when the
* rotation was triggered outside the scheduled timer (e.g. by the wake handler).
*/
add_task(async function test_successful_rotation_reschedules_next() {
let sandbox = sinon.createSandbox();
setupStubs({ validProxyPass: true });
await startActiveProxy();
// The pass returned by the manual rotation is due for rotation almost
// immediately, so the reschedule it triggers fires shortly after.
const now = Temporal.Now.instant();
const soonDuePass = new ProxyPass(
createProxyPassToken(now, now.add({ minutes: 2, seconds: 1 }))
);
const longLivedPass = new ProxyPass(createProxyPassToken());
// Resolves when the rescheduled rotation fires on its own and fetches again.
const rescheduledRotationRan = Promise.withResolvers();
const fetchStub = sandbox.stub(IPPDummyAuthProvider, "fetchProxyPass");
fetchStub.onCall(0).resolves({ status: 200, pass: soonDuePass });
fetchStub.callsFake(() => {
rescheduledRotationRan.resolve();
return Promise.resolve({ status: 200, pass: longLivedPass });
});
const rotated = await IPPProxyManager.rotateProxyPass();
Assert.equal(
rotated.token,
soonDuePass.token,
"Manual rotation returned the soon-due pass"
);
// If the rotation had not rescheduled, this would never resolve.
await rescheduledRotationRan.promise;
Assert.equal(
IPPProxyManager.state,
IPPProxyStates.ACTIVE,
"Proxy stays ACTIVE after the rescheduled rotation"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
sandbox.restore();
});
/**
* A pass that is always fetched already due for rotation must not spin the
* scheduled rotation into a tight loop while awake: the reschedule delay is
* floored by browser.ipProtection.guardian.minRotationInterval.
*/
add_task(async function test_scheduled_rotation_does_not_livelock() {
const MIN_ROTATION_INTERVAL_PREF =
"browser.ipProtection.guardian.minRotationInterval";
let sandbox = sinon.createSandbox();
setupStubs({ validProxyPass: true });
Services.prefs.setIntPref(MIN_ROTATION_INTERVAL_PREF, 100);
// Every fetch returns a pass that is already past its rotation point, so each
// scheduled rotation immediately schedules another one.
const now = Temporal.Now.instant();
const alwaysDue = new ProxyPass(
createProxyPassToken(now.subtract({ minutes: 2 }), now.add({ seconds: 1 }))
);
const fetchSpy = sandbox.spy(IPPDummyAuthProvider, "fetchProxyPass");
IPPDummyAuthProvider.setProxyPass({
status: 200,
error: undefined,
pass: alwaysDue,
usage: new ProxyUsage(
"5368709120",
"4294967296",
"3026-02-01T00:00:00.000Z"
),
});
await startActiveProxy();
const fetchesAfterStart = fetchSpy.callCount;
// Stay awake and let scheduled rotations run for several floor intervals.
await new Promise(resolve => do_timeout(500, resolve));
const scheduledRotations = fetchSpy.callCount - fetchesAfterStart;
Assert.lessOrEqual(
scheduledRotations,
20,
"Scheduled rotations are bounded by the minimum interval, not a busy loop"
);
await IPPProxyManager.stop();
IPProtectionService.uninit();
sandbox.restore();
Services.prefs.clearUserPref(MIN_ROTATION_INTERVAL_PREF);
});
function channelFilterProxyInfoPresent() {
return !!IPPProxyManager.channelFilter()?.proxyInfo;
}