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:
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"]