tor-browser

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

commit 39a57e37b919a576f9a5f68f252db65951661c76
parent 597ff721069b65e46c26c0cf059f5cdb36255751
Author: Robin Steuber <bytesized@mozilla.com>
Date:   Thu,  9 Oct 2025 23:58:23 +0000

Bug 1984458 - Add tests that `UpdateService` notices when `update.status` is inaccessible r=cdupuis,nrishel

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

Diffstat:
Mtoolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Atoolkit/mozapps/update/tests/unit_aus_update/accessAndLockout.js | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml | 4++++
3 files changed, 210 insertions(+), 4 deletions(-)

diff --git a/toolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js b/toolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js @@ -154,6 +154,7 @@ const APP_UPDATE_SJS_HOST = "http://127.0.0.1"; const APP_UPDATE_SJS_PATH = "/" + REL_PATH_DATA + "app_update.sjs"; var gIncrementalDownloadErrorType; +var gIncrementalDownloadCancelOk = false; var gResponseBody; @@ -5174,7 +5175,13 @@ IncrementalDownload.prototype = { /* nsIRequest */ cancel(_aStatus) { - throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + // We aren't actually going to do anything to cancel this. The tests should + // clean up the completed download either way, so it should never really + // matter if we actually finish it after calling this. But we want to throw + // an error if a test calls this unexpectedly. + if (!gIncrementalDownloadCancelOk) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } }, suspend() { throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); @@ -5697,6 +5704,12 @@ const EXIT_CODE = ${JSON.stringify(TestUpdateMutexCrossProcess.EXIT_CODE)}; * expectedDownloadResult * This function asserts that the download should finish with this * result. Defaults to `NS_OK`. + * expectedDownloadStartResult + * This function asserts that `AUS.downloadUpdate` return the + * expected value. Defaults to + * `Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS`. If a different + * value is specified, later checks that the download completed + * properly will be skipped. * incrementalDownloadErrorType * This can be used to specify an alternate value of * `gIncrementalDownloadErrorType`. The default value is `3`, which @@ -5719,6 +5732,7 @@ async function downloadUpdate({ expectDownloadRestriction, expectedCheckResult, expectedDownloadResult = Cr.NS_OK, + expectedDownloadStartResult = Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS, incrementalDownloadErrorType = 3, onDownloadStartCallback, slowDownload, @@ -5736,7 +5750,10 @@ async function downloadUpdate({ "update-download-restriction-hit" ); }); - } else { + } else if ( + expectedDownloadStartResult == + Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS + ) { downloadFinishedPromise = new Promise(resolve => gAUS.addDownloadListener({ onStartRequest: _aRequest => {}, @@ -5800,6 +5817,9 @@ async function downloadUpdate({ initMockIncrementalDownload(); gIncrementalDownloadErrorType = incrementalDownloadErrorType; + gIncrementalDownloadCancelOk = + expectedDownloadStartResult != + Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS; update = await gAUS.selectUpdate(updates); } @@ -5824,9 +5844,12 @@ async function downloadUpdate({ const result = await gAUS.downloadUpdate(update); Assert.equal( result, - Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS, - "nsIApplicationUpdateService:downloadUpdate should succeed" + expectedDownloadStartResult, + "nsIApplicationUpdateService:downloadUpdate status should be correct" ); + if (result != Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS) { + return; + } } if (waitToStartPromise) { @@ -5856,3 +5879,60 @@ async function downloadUpdate({ await TestUtils.waitForTick(); } } + +/** + * Holds a file open until it ought to be closed. + * + * @param file + * The `nsIFile` for the file to be held open + * @param shareMode + * Optional. The share mode (`dwShareMode`) to pass to `CreateFileW` + * when opening the file. If provided, should be a string containing + * a combination of 'r', 'w', and 'd' to indicate sharing for the + * read, write, and delete permissions, respectively. The default is to + * share nothing. + * @return An asynchronous function taking no arguments. When it is called and + * the returned promise resolves, the file is no longer being held open. + */ +async function holdFileOpen(file, shareMode) { + const testHelper = getTestDirFile("test_file_hold_open.exe", false); + + const args = [file.path]; + if (shareMode) { + args.push(shareMode); + } + + const proc = await Subprocess.call({ + command: testHelper.path, + arguments: args, + }); + const isLocked = await proc.stdout.readString(); + + if (isLocked.trim() != "Locked") { + throw new Error("Expected status to be Locked, found " + isLocked); + } + + return async () => { + await proc.stdin.write("q"); + const rc = await proc.wait(1000); + Assert.equal(rc.exitCode, 0, "Expected process to have successful exit"); + }; +} + +async function setFileModifiedAge(outfile, ageInSeconds) { + const outfilePath = outfile.path; + const testHelper = getTestDirFile("test_file_change_mtime.exe"); + + let proc = await Subprocess.call({ + command: testHelper.path, + arguments: [outfilePath, ageInSeconds], + }); + + let stdout; + while ((stdout = await proc.stdout.readString())) { + logTestInfo(stdout); + } + + const rc = await proc.wait(1000); // Wait for it to exit. + Assert.equal(rc.exitCode, 0, "Expected process to have successful exit"); +} diff --git a/toolkit/mozapps/update/tests/unit_aus_update/accessAndLockout.js b/toolkit/mozapps/update/tests/unit_aus_update/accessAndLockout.js @@ -0,0 +1,122 @@ +/* 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/. + */ + +const PREF_APP_UPDATE_LOCKEDOUT_COUNT = "app.update.lockedOut.count"; +const PREF_APP_UPDATE_LOCKEDOUT_DEBOUNCETIME = + "app.update.lockedOut.debounceTimeMs"; +const PREF_APP_UPDATE_LOCKEDOUT_MAXCOUNT = "app.update.lockedOut.maxCount"; +const PREF_APP_UPDATE_LOCKEDOUT_MAXAGE = "app.update.lockedOut.maxAgeMs"; + +const kMaxLockedOutCount = 10; +const kMaxStatusFileModifyAgeMs = 24 * 60 * 60 * 1000; // 1 day + +add_setup(async function setup() { + setupTestCommon(); + start_httpserver(); + setUpdateURL(gURLData + gHTTPHandlerPath); + setUpdateChannel("test_channel"); + + // FOG needs a profile directory to put its data in. + do_get_profile(); + Services.fog.initializeFOG(); +}); + +async function runLockedOutTest({ + isLockedOut, + statusFileIsOld, + lockoutsRemaining, +}) { + await reloadUpdateManagerData(); + await reInitUpdateService(); + Services.fog.testResetFOG(); + + const statusFile = getUpdateDirFile(FILE_UPDATE_STATUS); + + let lockoutCount = kMaxLockedOutCount - lockoutsRemaining; + Services.prefs.setIntPref(PREF_APP_UPDATE_LOCKEDOUT_COUNT, lockoutCount); + + // Write an empty status file to ensure that the path exists. + writeStatusFile(""); + if (statusFileIsOld) { + await setFileModifiedAge( + statusFile, + Math.floor((2 * kMaxStatusFileModifyAgeMs) / 1000) + ); + } else { + await setFileModifiedAge(statusFile, 0); + } + let unlockStatusFile; + if (isLockedOut) { + unlockStatusFile = await holdFileOpen(statusFile, "r"); + } else { + unlockStatusFile = async () => {}; + } + + const nextLockoutWillBeMax = lockoutsRemaining <= 1; + const expectNotifyUser = + isLockedOut && statusFileIsOld && nextLockoutWillBeMax; + const badPermsObserverPromise = expectNotifyUser + ? waitForEvent("update-error", "bad-perms") + : Promise.resolve(true); + const expectedDownloadStartResult = isLockedOut + ? Ci.nsIApplicationUpdateService.DOWNLOAD_FAILURE_CANNOT_WRITE_STATE + : Ci.nsIApplicationUpdateService.DOWNLOAD_SUCCESS; + + await downloadUpdate({ expectedDownloadStartResult }); + + const badPermsObserverResult = await badPermsObserverPromise; + Assert.ok(badPermsObserverResult); + + const newLockoutCount = Services.prefs.getIntPref( + PREF_APP_UPDATE_LOCKEDOUT_COUNT, + -1 + ); + if (!isLockedOut || expectNotifyUser) { + Assert.equal(newLockoutCount, 0, "lockout count should reset"); + } else { + Assert.equal( + newLockoutCount, + lockoutCount + 1, + "lockout count should incremented" + ); + } + if (expectNotifyUser) { + Assert.equal( + Glean.update.stateWriteFailure.testGetValue(), + 1, + "telemetry should be incremented" + ); + } else { + // Coerce the telemetry into an integer since this will generally return + // `null`. + Assert.equal( + Number(Glean.update.stateWriteFailure.testGetValue()), + 0, + "telemetry should not be incremented" + ); + } + + await unlockStatusFile(); +} + +add_task(async function testAccessAndLockout() { + Services.prefs.setIntPref( + PREF_APP_UPDATE_LOCKEDOUT_MAXCOUNT, + kMaxLockedOutCount + ); + Services.prefs.setIntPref(PREF_APP_UPDATE_LOCKEDOUT_DEBOUNCETIME, 0); + Services.prefs.setIntPref( + PREF_APP_UPDATE_LOCKEDOUT_MAXAGE, + kMaxStatusFileModifyAgeMs + ); + + await parameterizedTest(runLockedOutTest, { + isLockedOut: [true, false], + statusFileIsOld: [true, false], + lockoutsRemaining: [1, 2], + }); + + await doTestFinish(); +}); diff --git a/toolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml b/toolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml @@ -19,6 +19,10 @@ support-files = [ "../data/simple.mar", ] +["accessAndLockout.js"] +run-if = ["os == 'win'"] +reason = "Testing mechanism is Windows-only as it relies on CreateFileW" + ["ausReadStrings.js"] ["backgroundUpdateTaskInternalUpdater.js"]