test_BackupService.js (41562B)
1 /* Any copyright is dedicated to the Public Domain. 2 https://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 const { BasePromiseWorker } = ChromeUtils.importESModule( 7 "resource://gre/modules/PromiseWorker.sys.mjs" 8 ); 9 const { JsonSchema } = ChromeUtils.importESModule( 10 "resource://gre/modules/JsonSchema.sys.mjs" 11 ); 12 const { UIState } = ChromeUtils.importESModule( 13 "resource://services-sync/UIState.sys.mjs" 14 ); 15 const { ClientID } = ChromeUtils.importESModule( 16 "resource://gre/modules/ClientID.sys.mjs" 17 ); 18 const { ERRORS } = ChromeUtils.importESModule( 19 "chrome://browser/content/backup/backup-constants.mjs" 20 ); 21 22 const { TestUtils } = ChromeUtils.importESModule( 23 "resource://testing-common/TestUtils.sys.mjs" 24 ); 25 26 const LAST_BACKUP_TIMESTAMP_PREF_NAME = 27 "browser.backup.scheduled.last-backup-timestamp"; 28 const LAST_BACKUP_FILE_NAME_PREF_NAME = 29 "browser.backup.scheduled.last-backup-file"; 30 const BACKUP_ARCHIVE_ENABLED_PREF_NAME = "browser.backup.archive.enabled"; 31 const BACKUP_RESTORE_ENABLED_PREF_NAME = "browser.backup.restore.enabled"; 32 33 /** @type {nsIToolkitProfile} */ 34 let currentProfile; 35 36 // Mock backup metadata 37 const DATE = "2024-06-25T21:59:11.777Z"; 38 const IS_ENCRYPTED = true; 39 const DEVICE_NAME = "test-device"; 40 const APP_NAME = "test-app-name"; 41 const APP_VERSION = "test-app-version"; 42 const BUILD_ID = "test-build-id"; 43 const OS_NAME = "test-os-name"; 44 const OS_VERSION = "test-os-version"; 45 const TELEMETRY_ENABLED = true; 46 const LEGACY_CLIENT_ID = "legacy-client-id"; 47 48 add_setup(function () { 49 currentProfile = setupProfile(); 50 }); 51 52 /** 53 * A utility function for testing BackupService.createBackup. This helper 54 * function: 55 * 56 * 1. Produces a backup of fake resources 57 * 2. Recovers the backup into a new profile directory 58 * 3. Ensures that the resources had their backup/recovery methods called 59 * 60 * @param {object} sandbox 61 * The Sinon sandbox to be used stubs and mocks. The test using this helper 62 * is responsible for creating and resetting this sandbox. 63 * @param {function(BackupService, BackupManifest): void} taskFn 64 * A function that is run once all default checks are done. 65 * After this function returns, all resources will be cleaned up. 66 * @returns {Promise<undefined>} 67 */ 68 async function testCreateBackupHelper(sandbox, taskFn) { 69 Services.telemetry.clearEvents(); 70 Services.fog.testResetFOG(); 71 72 // Handle for the metric for total byte size of staging folder 73 let totalBackupSizeHistogram = TelemetryTestUtils.getAndClearHistogram( 74 "BROWSER_BACKUP_TOTAL_BACKUP_SIZE" 75 ); 76 // Handle for the metric for total byte size of single-file archive 77 let compressedArchiveSizeHistogram = TelemetryTestUtils.getAndClearHistogram( 78 "BROWSER_BACKUP_COMPRESSED_ARCHIVE_SIZE" 79 ); 80 // Handle for the metric for total time taking by profile backup 81 let backupTimerHistogram = TelemetryTestUtils.getAndClearHistogram( 82 "BROWSER_BACKUP_TOTAL_BACKUP_TIME_MS" 83 ); 84 85 const EXPECTED_CLIENT_ID = await ClientID.getClientID(); 86 const EXPECTED_PROFILE_GROUP_ID = await ClientID.getProfileGroupID(); 87 88 // Enable the scheduled backups pref so that backups can be deleted. We're 89 // not calling initBackupScheduler on the BackupService that we're 90 // constructing, so there's no danger of accidentally having a backup be 91 // created during this test if there's an idle period. 92 Services.prefs.setBoolPref("browser.backup.scheduled.enabled", true); 93 registerCleanupFunction(() => { 94 Services.prefs.clearUserPref("browser.backup.scheduled.enabled"); 95 }); 96 97 let fake1ManifestEntry = { fake1: "hello from 1" }; 98 sandbox 99 .stub(FakeBackupResource1.prototype, "backup") 100 .resolves(fake1ManifestEntry); 101 sandbox.stub(FakeBackupResource1.prototype, "recover").resolves(); 102 103 sandbox 104 .stub(FakeBackupResource2.prototype, "backup") 105 .rejects(new Error("Some failure to backup")); 106 sandbox.stub(FakeBackupResource2.prototype, "recover"); 107 108 let fake3ManifestEntry = { fake3: "hello from 3" }; 109 let fake3PostRecoveryEntry = { someData: "hello again from 3" }; 110 sandbox 111 .stub(FakeBackupResource3.prototype, "backup") 112 .resolves(fake3ManifestEntry); 113 sandbox 114 .stub(FakeBackupResource3.prototype, "recover") 115 .resolves(fake3PostRecoveryEntry); 116 117 let bs = BackupService.init({ 118 FakeBackupResource1, 119 FakeBackupResource2, 120 FakeBackupResource3, 121 }); 122 123 let fakeProfilePath = await IOUtils.createUniqueDirectory( 124 PathUtils.tempDir, 125 "createBackupTest" 126 ); 127 128 Assert.ok(!bs.state.lastBackupDate, "No backup date is stored in state."); 129 let { manifest, archivePath: backupFilePath } = await bs.createBackup({ 130 profilePath: fakeProfilePath, 131 }); 132 Assert.notStrictEqual( 133 bs.state.lastBackupDate, 134 null, 135 "The backup date was recorded." 136 ); 137 138 let legacyEvents = TelemetryTestUtils.getEvents( 139 { category: "browser.backup", method: "created", object: "BackupService" }, 140 { process: "parent" } 141 ); 142 Assert.equal(legacyEvents.length, 1, "Found the created legacy event."); 143 let events = Glean.browserBackup.created.testGetValue(); 144 Assert.equal(events.length, 1, "Found the created Glean event."); 145 146 // Validate total backup time metrics were recorded 147 assertSingleTimeMeasurement( 148 Glean.browserBackup.totalBackupTime.testGetValue() 149 ); 150 assertHistogramMeasurementQuantity( 151 backupTimerHistogram, 152 1, 153 "Should have collected a single measurement for total backup time" 154 ); 155 156 Assert.ok(await IOUtils.exists(backupFilePath), "The backup file exists"); 157 158 let archiveDateSuffix = bs.generateArchiveDateSuffix( 159 new Date(manifest.meta.date) 160 ); 161 162 // We also expect the HTML file to have been written to the folder pointed 163 // at by browser.backups.location, within backupDirPath folder. 164 const EXPECTED_ARCHIVE_PATH = PathUtils.join( 165 bs.state.backupDirPath, 166 `${BackupService.BACKUP_FILE_NAME}_${manifest.meta.profileName}_${archiveDateSuffix}.html` 167 ); 168 Assert.ok( 169 await IOUtils.exists(EXPECTED_ARCHIVE_PATH), 170 "Single-file backup archive was written." 171 ); 172 Assert.equal( 173 backupFilePath, 174 EXPECTED_ARCHIVE_PATH, 175 "Backup was written to the configured destination folder" 176 ); 177 178 let snapshotsDirectoryPath = PathUtils.join( 179 fakeProfilePath, 180 BackupService.PROFILE_FOLDER_NAME, 181 BackupService.SNAPSHOTS_FOLDER_NAME 182 ); 183 let snapshotsDirectoryContentsPaths = await IOUtils.getChildren( 184 snapshotsDirectoryPath 185 ); 186 let snapshotsDirectoryContents = await Promise.all( 187 snapshotsDirectoryContentsPaths.map(IOUtils.stat) 188 ); 189 let snapshotsDirectorySubdirectories = snapshotsDirectoryContents.filter( 190 file => file.type === "directory" 191 ); 192 Assert.equal( 193 snapshotsDirectorySubdirectories.length, 194 0, 195 "Snapshots directory should have had all staging folders cleaned up" 196 ); 197 198 // 1 mebibyte minimum recorded value if total data size is under 1 mebibyte 199 // This assumes that these BackupService tests do not create sizable fake files 200 const SMALLEST_BACKUP_SIZE_BYTES = 1048576; 201 const SMALLEST_BACKUP_SIZE_MEBIBYTES = 1; 202 203 // Validate total (uncompressed profile data) size 204 let totalBackupSize = Glean.browserBackup.totalBackupSize.testGetValue(); 205 Assert.equal( 206 totalBackupSize.count, 207 1, 208 "Should have collected a single measurement for the total backup size" 209 ); 210 Assert.equal( 211 totalBackupSize.sum, 212 SMALLEST_BACKUP_SIZE_BYTES, 213 "Should have collected the right value for the total backup size" 214 ); 215 TelemetryTestUtils.assertHistogram( 216 totalBackupSizeHistogram, 217 SMALLEST_BACKUP_SIZE_MEBIBYTES, 218 1 219 ); 220 221 // Validate final archive (compressed/encrypted profile data + HTML) size 222 let compressedArchiveSize = 223 Glean.browserBackup.compressedArchiveSize.testGetValue(); 224 Assert.equal( 225 compressedArchiveSize.count, 226 1, 227 "Should have collected a single measurement for the backup compressed archive size" 228 ); 229 Assert.equal( 230 compressedArchiveSize.sum, 231 SMALLEST_BACKUP_SIZE_BYTES, 232 "Should have collected the right value for the backup compressed archive size" 233 ); 234 TelemetryTestUtils.assertHistogram( 235 compressedArchiveSizeHistogram, 236 SMALLEST_BACKUP_SIZE_MEBIBYTES, 237 1 238 ); 239 240 // Check that resources were called from highest to lowest backup priority. 241 sinon.assert.callOrder( 242 FakeBackupResource3.prototype.backup, 243 FakeBackupResource2.prototype.backup, 244 FakeBackupResource1.prototype.backup 245 ); 246 247 let schema = await BackupService.MANIFEST_SCHEMA; 248 let validationResult = JsonSchema.validate(manifest, schema); 249 Assert.ok(validationResult.valid, "Schema matches manifest"); 250 Assert.deepEqual( 251 Object.keys(manifest.resources).sort(), 252 ["fake1", "fake3"], 253 "Manifest contains all expected BackupResource keys" 254 ); 255 Assert.deepEqual( 256 manifest.resources.fake1, 257 fake1ManifestEntry, 258 "Manifest contains the expected entry for FakeBackupResource1" 259 ); 260 Assert.deepEqual( 261 manifest.resources.fake3, 262 fake3ManifestEntry, 263 "Manifest contains the expected entry for FakeBackupResource3" 264 ); 265 Assert.equal( 266 manifest.meta.legacyClientID, 267 EXPECTED_CLIENT_ID, 268 "The client ID was stored properly." 269 ); 270 Assert.equal( 271 manifest.meta.profileGroupID, 272 EXPECTED_PROFILE_GROUP_ID, 273 "The profile group ID was stored properly." 274 ); 275 Assert.equal( 276 manifest.meta.profileName, 277 currentProfile.name, 278 "The profile name was stored properly" 279 ); 280 281 let recoveredProfilePath = await IOUtils.createUniqueDirectory( 282 PathUtils.tempDir, 283 "createBackupTestRecoveredProfile" 284 ); 285 286 let originalProfileName = currentProfile.name; 287 288 let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( 289 Ci.nsIToolkitProfileService 290 ); 291 // make our current profile default 292 profileSvc.defaultProfile = currentProfile; 293 294 await bs.getBackupFileInfo(backupFilePath); 295 const restoreID = bs.state.restoreID; 296 297 // Intercept the telemetry that we want to check for before it gets submitted 298 // and cleared out. 299 let restoreStartedEvents; 300 let restoreCompleteEvents; 301 let restoreCompleteCallback = () => { 302 Services.obs.removeObserver( 303 restoreCompleteCallback, 304 "browser-backup-restore-complete" 305 ); 306 restoreStartedEvents = Glean.browserBackup.restoreStarted.testGetValue(); 307 restoreCompleteEvents = Glean.browserBackup.restoreComplete.testGetValue(); 308 }; 309 Services.obs.addObserver( 310 restoreCompleteCallback, 311 "browser-backup-restore-complete" 312 ); 313 314 let recoveredProfile = await bs.recoverFromBackupArchive( 315 backupFilePath, 316 null, 317 false, 318 fakeProfilePath, 319 recoveredProfilePath 320 ); 321 322 Assert.ok( 323 recoveredProfile.name.startsWith(originalProfileName), 324 "Should maintain profile name across backup and restore" 325 ); 326 327 Assert.ok( 328 currentProfile.name.startsWith("old-"), 329 "The old profile should be prefixed with old-" 330 ); 331 332 Assert.strictEqual( 333 profileSvc.defaultProfile, 334 recoveredProfile, 335 "The new profile should now be the default" 336 ); 337 338 Assert.equal( 339 restoreStartedEvents.length, 340 1, 341 "Should be a single restore start event after we start restoring a profile" 342 ); 343 Assert.deepEqual( 344 restoreStartedEvents[0].extra, 345 { restore_id: restoreID }, 346 "Restore start event should have the right data" 347 ); 348 349 Assert.equal( 350 restoreCompleteEvents.length, 351 1, 352 "Should be a single restore complete event after we start restoring a profile" 353 ); 354 Assert.deepEqual( 355 restoreCompleteEvents[0].extra, 356 { restore_id: restoreID }, 357 "Restore complete event should have the right data" 358 ); 359 360 // Check that resources were recovered from highest to lowest backup priority. 361 sinon.assert.callOrder( 362 FakeBackupResource3.prototype.recover, 363 FakeBackupResource1.prototype.recover 364 ); 365 366 let postRecoveryFilePath = PathUtils.join( 367 recoveredProfilePath, 368 BackupService.POST_RECOVERY_FILE_NAME 369 ); 370 Assert.ok( 371 await IOUtils.exists(postRecoveryFilePath), 372 "Should have created post-recovery data file" 373 ); 374 let postRecoveryData = await IOUtils.readJSON(postRecoveryFilePath); 375 Assert.deepEqual( 376 postRecoveryData.fake3, 377 fake3PostRecoveryEntry, 378 "Should have post-recovery data from fake backup 3" 379 ); 380 381 await Assert.rejects( 382 IOUtils.readJSON( 383 PathUtils.join(recoveredProfilePath, "datareporting", "state.json") 384 ), 385 /file does not exist/, 386 "The telemetry state was cleared." 387 ); 388 389 await taskFn(bs, manifest); 390 391 await maybeRemovePath(backupFilePath); 392 await maybeRemovePath(fakeProfilePath); 393 await maybeRemovePath(recoveredProfilePath); 394 await maybeRemovePath(EXPECTED_ARCHIVE_PATH); 395 396 Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME); 397 398 BackupService.uninit(); 399 } 400 401 /** 402 * A utility function for testing BackupService.deleteLastBackup. This helper 403 * function: 404 * 405 * 1. Clears any pre-existing cached preference values for the last backup 406 * date and file name. 407 * 2. Uses testCreateBackupHelper to create a backup file. 408 * 3. Ensures that the state has been updated to reflect the created backup, 409 * and that the backup date and file name are cached to preferences. 410 * 4. Runs an optional async taskFn 411 * 5. Calls deleteLastBackup on the testCreateBackupHelper BackupService 412 * instance. 413 * 6. Checks that the BackupService state for the last backup date and file name 414 * have been cleared, and that the preferences caches of those values have 415 * also been cleared. 416 * 417 * @param {function(string): Promise<void>|null} taskFn 418 * An optional function that is run after we've created a backup, but just 419 * before calling deleteLastBackup(). It is passed the path to the created 420 * backup file. 421 * @returns {Promise<undefined>} 422 */ 423 async function testDeleteLastBackupHelper(taskFn) { 424 let sandbox = sinon.createSandbox(); 425 426 // Clear any last backup filenames and timestamps that might be lingering 427 // from prior tests. 428 Services.prefs.clearUserPref(LAST_BACKUP_TIMESTAMP_PREF_NAME); 429 Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME); 430 431 await testCreateBackupHelper(sandbox, async (bs, _manifest) => { 432 Assert.notStrictEqual( 433 bs.state.lastBackupDate, 434 null, 435 "Should have a last backup date recorded." 436 ); 437 Assert.ok( 438 bs.state.lastBackupFileName, 439 "Should have a last backup file name recorded." 440 ); 441 Assert.ok( 442 Services.prefs.prefHasUserValue(LAST_BACKUP_TIMESTAMP_PREF_NAME), 443 "Last backup date was cached in preferences." 444 ); 445 Assert.ok( 446 Services.prefs.prefHasUserValue(LAST_BACKUP_FILE_NAME_PREF_NAME), 447 "Last backup file name was cached in preferences." 448 ); 449 const LAST_BACKUP_FILE_PATH = PathUtils.join( 450 bs.state.backupDirPath, 451 bs.state.lastBackupFileName 452 ); 453 Assert.ok( 454 await IOUtils.exists(LAST_BACKUP_FILE_PATH), 455 "The backup file was created and is still on the disk." 456 ); 457 458 if (taskFn) { 459 await taskFn(LAST_BACKUP_FILE_PATH); 460 } 461 462 // NB: On Windows, deletes of backups in tests run into an issue where 463 // the file is locked briefly by the system and deletes fail with 464 // NS_ERROR_FILE_IS_LOCKED. See doFileRemovalOperation for details. 465 // We therefore retry this delete a few times before accepting failure. 466 await doFileRemovalOperation(async () => await bs.deleteLastBackup()); 467 468 Assert.equal( 469 bs.state.lastBackupDate, 470 null, 471 "Should have cleared the last backup date" 472 ); 473 Assert.equal( 474 bs.state.lastBackupFileName, 475 "", 476 "Should have cleared the last backup file name" 477 ); 478 Assert.ok( 479 !Services.prefs.prefHasUserValue(LAST_BACKUP_TIMESTAMP_PREF_NAME), 480 "Last backup date was cleared in preferences." 481 ); 482 Assert.ok( 483 !Services.prefs.prefHasUserValue(LAST_BACKUP_FILE_NAME_PREF_NAME), 484 "Last backup file name was cleared in preferences." 485 ); 486 Assert.ok( 487 !(await IOUtils.exists(LAST_BACKUP_FILE_PATH)), 488 "The backup file was deleted." 489 ); 490 }); 491 492 sandbox.restore(); 493 } 494 495 /** 496 * Tests that calling BackupService.createBackup will call backup on each 497 * registered BackupResource, and that each BackupResource will have a folder 498 * created for them to write into. Tests in the signed-out state. 499 */ 500 add_task(async function test_createBackup_signed_out() { 501 let sandbox = sinon.createSandbox(); 502 503 sandbox 504 .stub(UIState, "get") 505 .returns({ status: UIState.STATUS_NOT_CONFIGURED }); 506 await testCreateBackupHelper(sandbox, (_bs, manifest) => { 507 Assert.equal( 508 manifest.meta.accountID, 509 undefined, 510 "Account ID should be undefined." 511 ); 512 Assert.equal( 513 manifest.meta.accountEmail, 514 undefined, 515 "Account email should be undefined." 516 ); 517 }); 518 519 sandbox.restore(); 520 }); 521 522 /** 523 * Tests that calling BackupService.createBackup will call backup on each 524 * registered BackupResource, and that each BackupResource will have a folder 525 * created for them to write into. Tests in the signed-in state. 526 */ 527 add_task(async function test_createBackup_signed_in() { 528 let sandbox = sinon.createSandbox(); 529 530 const TEST_UID = "ThisIsMyTestUID"; 531 const TEST_EMAIL = "foxy@mozilla.org"; 532 533 sandbox.stub(UIState, "get").returns({ 534 status: UIState.STATUS_SIGNED_IN, 535 uid: TEST_UID, 536 email: TEST_EMAIL, 537 }); 538 539 await testCreateBackupHelper(sandbox, (_bs, manifest) => { 540 Assert.equal( 541 manifest.meta.accountID, 542 TEST_UID, 543 "Account ID should be set properly." 544 ); 545 Assert.equal( 546 manifest.meta.accountEmail, 547 TEST_EMAIL, 548 "Account email should be set properly." 549 ); 550 }); 551 552 sandbox.restore(); 553 }); 554 555 /** 556 * Makes a folder readonly. Windows does not support read-only folders, so 557 * this creates a file inside the folder and makes that read-only. 558 * 559 * @param {string} folderpath Full path to folder to make read-only. 560 * @param {boolean} isReadonly Whether to set or clear read-only status. 561 */ 562 async function makeFolderReadonly(folderpath, isReadonly) { 563 if (AppConstants.platform !== "win") { 564 await IOUtils.setPermissions(folderpath, isReadonly ? 0o444 : 0o666); 565 let folder = await IOUtils.getFile(folderpath); 566 Assert.equal( 567 folder.isWritable(), 568 !isReadonly, 569 `folder is ${isReadonly ? "" : "not "}read-only` 570 ); 571 } else if (isReadonly) { 572 // Permissions flags like 0o444 are not usually respected on Windows but in 573 // the case of creating a unique file, the read-only status is. See 574 // OpenFile in nsLocalFileWin.cpp. 575 let tempFilename = await IOUtils.createUniqueFile( 576 folderpath, 577 "readonlyfile", 578 0o444 579 ); 580 let file = await IOUtils.getFile(tempFilename); 581 Assert.equal(file.isWritable(), false, "file in folder is read-only"); 582 } else { 583 // Recursively set any folder contents to be writeable. 584 let attrs = await IOUtils.getWindowsAttributes(folderpath); 585 attrs.readonly = false; 586 await IOUtils.setWindowsAttributes(folderpath, attrs, true /* recursive */); 587 } 588 } 589 590 /** 591 * Tests that read-only files in BackupService.createBackup cause backup 592 * failure (createBackup returns null) and does not bubble up any errors. 593 */ 594 add_task( 595 { 596 // We override read-only on Windows -- see 597 // test_createBackup_override_readonly below. 598 skip_if: () => AppConstants.platform == "win", 599 }, 600 async function test_createBackup_robustToFileSystemErrors() { 601 let sandbox = sinon.createSandbox(); 602 Services.fog.testResetFOG(); 603 // Handle for the metric for total time taking by profile backup 604 let backupTimerHistogram = TelemetryTestUtils.getAndClearHistogram( 605 "BROWSER_BACKUP_TOTAL_BACKUP_TIME_MS" 606 ); 607 608 const TEST_UID = "ThisIsMyTestUID"; 609 const TEST_EMAIL = "foxy@mozilla.org"; 610 611 sandbox.stub(UIState, "get").returns({ 612 status: UIState.STATUS_SIGNED_IN, 613 uid: TEST_UID, 614 email: TEST_EMAIL, 615 }); 616 617 // Create a read-only fake profile folder to which the backup service 618 // won't be able to make writes 619 let inaccessibleProfilePath = await IOUtils.createUniqueDirectory( 620 PathUtils.tempDir, 621 "createBackupErrorReadonly" 622 ); 623 await makeFolderReadonly(inaccessibleProfilePath, true); 624 625 const bs = new BackupService({}); 626 627 await bs 628 .createBackup({ profilePath: inaccessibleProfilePath }) 629 .then(result => { 630 Assert.equal(result, null, "Should return null on error"); 631 632 // Validate total backup time metrics were recorded 633 const totalBackupTime = 634 Glean.browserBackup.totalBackupTime.testGetValue(); 635 Assert.equal( 636 totalBackupTime, 637 null, 638 "Should not have measured total backup time for failed backup" 639 ); 640 assertHistogramMeasurementQuantity(backupTimerHistogram, 0); 641 }) 642 .catch(() => { 643 // Failure bubbles up an error for handling by the caller 644 }) 645 .finally(async () => { 646 await makeFolderReadonly(inaccessibleProfilePath, false); 647 await IOUtils.remove(inaccessibleProfilePath, { recursive: true }); 648 sandbox.restore(); 649 }); 650 } 651 ); 652 653 /** 654 * Tests that BackupService.createBackup can override simple read-only status 655 * when handling staging files. That currently only works on Windows. 656 */ 657 add_task( 658 { 659 skip_if: () => AppConstants.platform !== "win", 660 }, 661 async function test_createBackup_override_readonly() { 662 let sandbox = sinon.createSandbox(); 663 664 const TEST_UID = "ThisIsMyTestUID"; 665 const TEST_EMAIL = "foxy@mozilla.org"; 666 667 sandbox.stub(UIState, "get").returns({ 668 status: UIState.STATUS_SIGNED_IN, 669 uid: TEST_UID, 670 email: TEST_EMAIL, 671 }); 672 673 // Create a fake profile folder that contains a read-only file. We do this 674 // because Windows does not respect read-only status on folders. The 675 // file's read-only status will make the folder un(re)movable. 676 let inaccessibleProfilePath = await IOUtils.createUniqueDirectory( 677 PathUtils.tempDir, 678 "createBackupErrorReadonly" 679 ); 680 await makeFolderReadonly(inaccessibleProfilePath, true); 681 await Assert.rejects( 682 IOUtils.remove(inaccessibleProfilePath), 683 /Could not remove/, 684 "folder is not removable" 685 ); 686 687 const bs = new BackupService({}); 688 689 await bs 690 .createBackup({ profilePath: inaccessibleProfilePath }) 691 .then(result => { 692 Assert.notEqual(result, null, "Should not return null on success"); 693 }) 694 .catch(e => { 695 console.error(e); 696 Assert.ok(false, "Should not have bubbled up an error"); 697 }) 698 .finally(async () => { 699 await makeFolderReadonly(inaccessibleProfilePath, false); 700 await IOUtils.remove(inaccessibleProfilePath, { recursive: true }); 701 await bs.deleteLastBackup(); 702 sandbox.restore(); 703 }); 704 } 705 ); 706 707 /** 708 * Creates a unique file in the given folder and tells a worker to keep it 709 * open until we post a close message. Checks that the folder is not 710 * removable as a result. 711 * 712 * @param {string} folderpath 713 * @returns {object} {{ path: string, worker: OpenFileWorker }} 714 */ 715 async function openUniqueFileInFolder(folderpath) { 716 let testFile = await IOUtils.createUniqueFile(folderpath, "openfile"); 717 await IOUtils.writeUTF8(testFile, ""); 718 Assert.ok( 719 await IOUtils.exists(testFile), 720 testFile + " should have been created" 721 ); 722 // Use a worker to keep the testFile open. 723 const worker = new BasePromiseWorker( 724 "resource://test/data/test_keep_file_open.worker.js" 725 ); 726 await worker.post("open", [testFile]); 727 728 await Assert.rejects( 729 IOUtils.remove(folderpath), 730 /NS_ERROR_FILE_DIR_NOT_EMPTY/, 731 "attempt to remove folder threw an exception" 732 ); 733 Assert.ok(await IOUtils.exists(folderpath), "folder is not removable"); 734 return { path: testFile, worker }; 735 } 736 737 /** 738 * Stop the worker returned from openUniqueFileInFolder and close the file. 739 * 740 * @param {object} worker The worker returned by openUniqueFileInFolder 741 */ 742 async function closeTestFile(worker) { 743 await worker.post("close", []); 744 } 745 746 /** 747 * Run a backup and check that it either succeeded with a response or failed 748 * and returned null. 749 * 750 * @param {object} backupService Instance of BackupService 751 * @param {string} profilePath Full path to profile folder 752 * @param {boolean} shouldSucceed Whether to expect success or failure 753 */ 754 async function checkBackup(backupService, profilePath, shouldSucceed) { 755 if (shouldSucceed) { 756 await backupService.createBackup({ profilePath }).then(result => { 757 Assert.ok(true, "createBackup did not throw an exception"); 758 Assert.notEqual( 759 result, 760 null, 761 `createBackup should not have returned null` 762 ); 763 }); 764 await backupService.deleteLastBackup(); 765 return; 766 } 767 768 await Assert.rejects( 769 backupService.createBackup({ profilePath }), 770 /Failed to remove/, 771 "createBackup threw correct exception" 772 ); 773 } 774 775 /** 776 * Checks that browser.backup.max-num-unremovable-staging-items allows backups 777 * to succeed if the snapshots folder contains no more than that many 778 * unremovable items, and that it fails if there are more than that. 779 * 780 * @param {number} unremovableItemsLimit Max number of unremovable items that 781 * backups can succeed with. 782 */ 783 async function checkBackupWithUnremovableItems(unremovableItemsLimit) { 784 Services.prefs.setIntPref( 785 "browser.backup.max-num-unremovable-staging-items", 786 unremovableItemsLimit 787 ); 788 registerCleanupFunction(() => 789 Services.prefs.clearUserPref( 790 "browser.backup.max-num-unremovable-staging-items" 791 ) 792 ); 793 794 let sandbox = sinon.createSandbox(); 795 796 const TEST_UID = "ThisIsMyTestUID"; 797 const TEST_EMAIL = "foxy@mozilla.org"; 798 799 sandbox.stub(UIState, "get").returns({ 800 status: UIState.STATUS_SIGNED_IN, 801 uid: TEST_UID, 802 email: TEST_EMAIL, 803 }); 804 const backupService = new BackupService({}); 805 806 let profilePath = await IOUtils.createUniqueDirectory( 807 PathUtils.tempDir, 808 "profileDir" 809 ); 810 let snapshotsFolder = PathUtils.join( 811 profilePath, 812 BackupService.PROFILE_FOLDER_NAME, 813 BackupService.SNAPSHOTS_FOLDER_NAME 814 ); 815 816 let openFileWorkers = []; 817 try { 818 for (let i = 0; i < unremovableItemsLimit + 1; i++) { 819 info(`Performing backup #${i}`); 820 await checkBackup(backupService, profilePath, true /* shouldSucceed */); 821 822 // Create and open a file so that the snapshots folder cannot be 823 // emptied. 824 openFileWorkers.push(await openUniqueFileInFolder(snapshotsFolder)); 825 } 826 827 // We are now over the unremovableItemsLimit. 828 info(`Performing backup that should fail`); 829 await checkBackup(backupService, profilePath, false /* shouldSucceed */); 830 } finally { 831 await Promise.all( 832 openFileWorkers.map(async ofw => await closeTestFile(ofw.worker)) 833 ); 834 await Promise.all( 835 openFileWorkers.map(async ofw => await IOUtils.remove(ofw.path)) 836 ); 837 await IOUtils.remove(profilePath, { recursive: true }); 838 sandbox.restore(); 839 } 840 } 841 842 /** 843 * Tests that any non-read-only file deletion errors do not prevent backups 844 * until the browser.backup.max-num-unremovable-staging-items limit has been 845 * reached. 846 */ 847 add_task( 848 async function test_createBackup_robustToNonReadonlyFileSystemErrorsAllowOneNonReadonly() { 849 await checkBackupWithUnremovableItems(1); 850 } 851 ); 852 853 /** 854 * Tests that browser.backup.max-num-unremovable-staging-items works for value 855 * 0. 856 */ 857 add_task( 858 async function test_createBackup_robustToNonReadonlyFileSystemErrors() { 859 await checkBackupWithUnremovableItems(0); 860 } 861 ); 862 863 /** 864 * Tests that failure to delete the prior backup doesn't prevent the backup 865 * location from being edited. 866 */ 867 add_task( 868 async function test_editBackupLocation_robustToDeleteLastBackupException() { 869 const backupLocationPref = "browser.backup.location"; 870 const resetLocation = Services.prefs.getStringPref(backupLocationPref); 871 872 const exceptionBackupLocation = await IOUtils.createUniqueDirectory( 873 PathUtils.tempDir, 874 "exceptionBackupLocation" 875 ); 876 Services.prefs.setStringPref(backupLocationPref, exceptionBackupLocation); 877 878 const newBackupLocation = await IOUtils.createUniqueDirectory( 879 PathUtils.tempDir, 880 "newBackupLocation" 881 ); 882 883 let pickerDir = await IOUtils.getDirectory(newBackupLocation); 884 const reg = MockRegistrar.register("@mozilla.org/filepicker;1", { 885 init() {}, 886 open(cb) { 887 cb.done(Ci.nsIFilePicker.returnOK); 888 }, 889 displayDirectory: null, 890 file: pickerDir, 891 QueryInterface: ChromeUtils.generateQI(["nsIFilePicker"]), 892 }); 893 894 const backupService = new BackupService({}); 895 896 const sandbox = sinon.createSandbox(); 897 sandbox 898 .stub(backupService, "deleteLastBackup") 899 .rejects(new Error("Exception while deleting backup")); 900 901 await backupService.editBackupLocation({ browsingContext: null }); 902 903 pickerDir.append("Restore Firefox"); 904 Assert.equal( 905 Services.prefs.getStringPref(backupLocationPref), 906 pickerDir.path, 907 "Backup location pref should have updated to the new directory." 908 ); 909 910 Services.prefs.setStringPref(backupLocationPref, resetLocation); 911 sinon.restore(); 912 MockRegistrar.unregister(reg); 913 await Promise.all([ 914 IOUtils.remove(exceptionBackupLocation, { recursive: true }), 915 IOUtils.remove(newBackupLocation, { recursive: true }), 916 ]); 917 } 918 ); 919 920 /** 921 * Tests that the existence of selectable profiles prevent backups (see bug 922 * 1990980). 923 * 924 * @param {boolean} aSetCreatedSelectableProfilesBeforeSchedulingBackups 925 * If true (respectively, false), set browser.profiles.created before 926 * (respectively, after) attempting to setScheduledBackups. 927 */ 928 async function testSelectableProfilesPreventBackup( 929 aSetCreatedSelectableProfilesBeforeSchedulingBackups 930 ) { 931 let sandbox = sinon.createSandbox(); 932 Services.fog.testResetFOG(); 933 const TEST_UID = "ThisIsMyTestUID"; 934 const TEST_EMAIL = "foxy@mozilla.org"; 935 sandbox.stub(UIState, "get").returns({ 936 status: UIState.STATUS_SIGNED_IN, 937 uid: TEST_UID, 938 email: TEST_EMAIL, 939 }); 940 941 const SELECTABLE_PROFILES_CREATED_PREF = "browser.profiles.created"; 942 943 Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, true); 944 Services.prefs.setBoolPref(BACKUP_RESTORE_ENABLED_PREF_NAME, true); 945 946 // Make sure created profiles pref is not set until we want it to be. 947 Services.prefs.setBoolPref(SELECTABLE_PROFILES_CREATED_PREF, false); 948 949 const setHasSelectableProfiles = () => { 950 // "Enable" selectable profiles by pref. 951 Services.prefs.setBoolPref(SELECTABLE_PROFILES_CREATED_PREF, true); 952 Assert.ok( 953 Services.prefs.getBoolPref(SELECTABLE_PROFILES_CREATED_PREF), 954 "set has selectable profiles | browser.profiles.created = true" 955 ); 956 }; 957 958 if (aSetCreatedSelectableProfilesBeforeSchedulingBackups) { 959 setHasSelectableProfiles(); 960 } 961 962 let bs = new BackupService({}); 963 bs.initBackupScheduler(); 964 bs.setScheduledBackups(true); 965 966 const SCHEDULED_BACKUP_ENABLED_PREF = "browser.backup.scheduled.enabled"; 967 if (!aSetCreatedSelectableProfilesBeforeSchedulingBackups) { 968 Assert.ok( 969 Services.prefs.getBoolPref(SCHEDULED_BACKUP_ENABLED_PREF, true), 970 "enabled scheduled backups | browser.backup.scheduled.enabled = true" 971 ); 972 registerCleanupFunction(() => { 973 // Just in case the test fails. 974 bs.setScheduledBackups(false); 975 info("cleared scheduled backups"); 976 }); 977 978 setHasSelectableProfiles(); 979 } 980 981 // Backups attempts should be rejected because of selectable profiles. 982 let fakeProfilePath = await IOUtils.createUniqueDirectory( 983 PathUtils.tempDir, 984 "testSelectableProfilesPreventBackup" 985 ); 986 registerCleanupFunction(async () => { 987 await maybeRemovePath(fakeProfilePath); 988 }); 989 let failedBackup = await bs.createBackup({ 990 profilePath: fakeProfilePath, 991 }); 992 Assert.equal(failedBackup, null, "Backup returned null"); 993 994 // Test cleanup 995 if (!aSetCreatedSelectableProfilesBeforeSchedulingBackups) { 996 bs.uninitBackupScheduler(); 997 } 998 999 Services.prefs.clearUserPref(SELECTABLE_PROFILES_CREATED_PREF); 1000 // These tests assume that backups and restores have been enabled. 1001 Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, true); 1002 Services.prefs.setBoolPref(BACKUP_RESTORE_ENABLED_PREF_NAME, true); 1003 sandbox.restore(); 1004 } 1005 1006 add_task( 1007 async function test_managing_profiles_before_scheduling_prevents_backup() { 1008 await testSelectableProfilesPreventBackup( 1009 true /* aSetCreatedSelectableProfilesBeforeSchedulingBackups */ 1010 ); 1011 } 1012 ); 1013 1014 add_task( 1015 async function test_managing_profiles_after_scheduling_prevents_backup() { 1016 await testSelectableProfilesPreventBackup( 1017 false /* aSetCreatedSelectableProfilesBeforeSchedulingBackups */ 1018 ); 1019 } 1020 ); 1021 1022 /** 1023 * Tests that if there's a post-recovery.json file in the profile directory 1024 * when checkForPostRecovery() is called, that it is processed, and the 1025 * postRecovery methods on the associated BackupResources are called with the 1026 * entry values from the file. 1027 */ 1028 add_task(async function test_checkForPostRecovery() { 1029 let sandbox = sinon.createSandbox(); 1030 1031 let testProfilePath = await IOUtils.createUniqueDirectory( 1032 PathUtils.tempDir, 1033 "checkForPostRecoveryTest" 1034 ); 1035 let fakePostRecoveryObject = { 1036 [FakeBackupResource1.key]: "test 1", 1037 [FakeBackupResource3.key]: "test 3", 1038 }; 1039 await IOUtils.writeJSON( 1040 PathUtils.join(testProfilePath, BackupService.POST_RECOVERY_FILE_NAME), 1041 fakePostRecoveryObject 1042 ); 1043 1044 sandbox.stub(FakeBackupResource1.prototype, "postRecovery").resolves(); 1045 sandbox.stub(FakeBackupResource2.prototype, "postRecovery").resolves(); 1046 sandbox.stub(FakeBackupResource3.prototype, "postRecovery").resolves(); 1047 1048 let bs = new BackupService({ 1049 FakeBackupResource1, 1050 FakeBackupResource2, 1051 FakeBackupResource3, 1052 }); 1053 1054 await bs.checkForPostRecovery(testProfilePath); 1055 await bs.postRecoveryComplete; 1056 1057 Assert.ok( 1058 FakeBackupResource1.prototype.postRecovery.calledOnce, 1059 "FakeBackupResource1.postRecovery was called once" 1060 ); 1061 Assert.ok( 1062 FakeBackupResource2.prototype.postRecovery.notCalled, 1063 "FakeBackupResource2.postRecovery was not called" 1064 ); 1065 Assert.ok( 1066 FakeBackupResource3.prototype.postRecovery.calledOnce, 1067 "FakeBackupResource3.postRecovery was called once" 1068 ); 1069 Assert.ok( 1070 FakeBackupResource1.prototype.postRecovery.calledWith( 1071 fakePostRecoveryObject[FakeBackupResource1.key] 1072 ), 1073 "FakeBackupResource1.postRecovery was called with the expected argument" 1074 ); 1075 Assert.ok( 1076 FakeBackupResource3.prototype.postRecovery.calledWith( 1077 fakePostRecoveryObject[FakeBackupResource3.key] 1078 ), 1079 "FakeBackupResource3.postRecovery was called with the expected argument" 1080 ); 1081 1082 await IOUtils.remove(testProfilePath, { recursive: true }); 1083 sandbox.restore(); 1084 }); 1085 1086 /** 1087 * Tests that getBackupFileInfo updates backupFileInfo in the state with a subset 1088 * of info from the fake SampleArchiveResult returned by sampleArchive(). 1089 */ 1090 add_task(async function test_getBackupFileInfo() { 1091 let sandbox = sinon.createSandbox(); 1092 1093 let fakeSampleArchiveResult = { 1094 isEncrypted: IS_ENCRYPTED, 1095 startByteOffset: 26985, 1096 contentType: "multipart/mixed", 1097 archiveJSON: { 1098 version: 1, 1099 meta: { 1100 date: DATE, 1101 deviceName: DEVICE_NAME, 1102 appName: APP_NAME, 1103 appVersion: APP_VERSION, 1104 buildID: BUILD_ID, 1105 osName: OS_NAME, 1106 osVersion: OS_VERSION, 1107 healthTelemetryEnabled: TELEMETRY_ENABLED, 1108 legacyClientID: LEGACY_CLIENT_ID, 1109 }, 1110 encConfig: {}, 1111 }, 1112 }; 1113 1114 sandbox 1115 .stub(BackupService.prototype, "sampleArchive") 1116 .resolves(fakeSampleArchiveResult); 1117 1118 let bs = new BackupService(); 1119 1120 await bs.getBackupFileInfo("fake-archive.html"); 1121 1122 Assert.ok( 1123 BackupService.prototype.sampleArchive.calledOnce, 1124 "sampleArchive was called once" 1125 ); 1126 1127 Assert.deepEqual( 1128 bs.state.backupFileInfo, 1129 { 1130 isEncrypted: IS_ENCRYPTED, 1131 date: DATE, 1132 deviceName: DEVICE_NAME, 1133 appName: APP_NAME, 1134 appVersion: APP_VERSION, 1135 buildID: BUILD_ID, 1136 osName: OS_NAME, 1137 osVersion: OS_VERSION, 1138 healthTelemetryEnabled: TELEMETRY_ENABLED, 1139 legacyClientID: LEGACY_CLIENT_ID, 1140 }, 1141 "State should match a subset from the archive sample." 1142 ); 1143 1144 sandbox.restore(); 1145 }); 1146 1147 /** 1148 * Tests that deleting the last backup will delete the last known backup file if 1149 * it exists, and will clear the last backup timestamp and filename state 1150 * properties and preferences. 1151 */ 1152 add_task(async function test_deleteLastBackup_file_exists() { 1153 await testDeleteLastBackupHelper(); 1154 }); 1155 1156 /** 1157 * Tests that deleting the last backup does not reject if the last backup file 1158 * does not exist, and will still clear the last backup timestamp and filename 1159 * state properties and preferences. 1160 */ 1161 add_task(async function test__deleteLastBackup_file_does_not_exist() { 1162 // Now delete the file ourselves before we call deleteLastBackup, 1163 // so that it's missing from the disk. 1164 await testDeleteLastBackupHelper(async lastBackupFilePath => { 1165 await maybeRemovePath(lastBackupFilePath); 1166 }); 1167 }); 1168 1169 /** 1170 * Tests that getBackupFileInfo properly handles errors, and clears file info 1171 * for errors that indicate that the file is invalid. 1172 */ 1173 add_task(async function test_getBackupFileInfo_error_handling() { 1174 let sandbox = sinon.createSandbox(); 1175 1176 const errorTypes = [ 1177 ERRORS.FILE_SYSTEM_ERROR, 1178 ERRORS.CORRUPTED_ARCHIVE, 1179 ERRORS.UNSUPPORTED_BACKUP_VERSION, 1180 ERRORS.INTERNAL_ERROR, 1181 ERRORS.UNINITIALIZED, 1182 ERRORS.INVALID_PASSWORD, 1183 ]; 1184 1185 for (const testError of errorTypes) { 1186 let bs = new BackupService(); 1187 1188 let fakeSampleArchiveResult = { 1189 isEncrypted: IS_ENCRYPTED, 1190 startByteOffset: 26985, 1191 contentType: "multipart/mixed", 1192 archiveJSON: { 1193 version: 1, 1194 meta: { 1195 date: DATE, 1196 deviceName: DEVICE_NAME, 1197 appName: APP_NAME, 1198 appVersion: APP_VERSION, 1199 buildID: BUILD_ID, 1200 osName: OS_NAME, 1201 osVersion: OS_VERSION, 1202 healthTelemetryEnabled: TELEMETRY_ENABLED, 1203 legacyClientID: LEGACY_CLIENT_ID, 1204 }, 1205 encConfig: {}, 1206 }, 1207 }; 1208 1209 sandbox 1210 .stub(BackupService.prototype, "sampleArchive") 1211 .resolves(fakeSampleArchiveResult); 1212 await bs.getBackupFileInfo("test-backup.html"); 1213 1214 // Verify initial state was set 1215 Assert.deepEqual( 1216 bs.state.backupFileInfo, 1217 { 1218 isEncrypted: IS_ENCRYPTED, 1219 date: DATE, 1220 deviceName: DEVICE_NAME, 1221 appName: APP_NAME, 1222 appVersion: APP_VERSION, 1223 buildID: BUILD_ID, 1224 osName: OS_NAME, 1225 osVersion: OS_VERSION, 1226 healthTelemetryEnabled: TELEMETRY_ENABLED, 1227 legacyClientID: LEGACY_CLIENT_ID, 1228 }, 1229 "Initial state should be set correctly" 1230 ); 1231 Assert.strictEqual( 1232 bs.state.backupFileToRestore, 1233 "test-backup.html", 1234 "Initial backupFileToRestore should be set correctly" 1235 ); 1236 1237 // Test when sampleArchive throws an error 1238 sandbox.restore(); 1239 sandbox 1240 .stub(BackupService.prototype, "sampleArchive") 1241 .rejects(new Error("Test error", { cause: testError })); 1242 const setRecoveryErrorStub = sandbox.stub(bs, "setRecoveryError"); 1243 1244 try { 1245 await bs.getBackupFileInfo("test-backup.html"); 1246 } catch (error) { 1247 Assert.ok( 1248 false, 1249 `Expected getBackupFileInfo to throw for error ${testError}` 1250 ); 1251 } 1252 1253 Assert.ok( 1254 setRecoveryErrorStub.calledOnceWith(testError), 1255 `setRecoveryError should be called with ${testError}` 1256 ); 1257 1258 Assert.strictEqual( 1259 bs.state.backupFileInfo, 1260 null, 1261 `backupFileInfo should be cleared for error ${testError}` 1262 ); 1263 Assert.strictEqual( 1264 bs.state.backupFileToRestore, 1265 null, 1266 `backupFileToRestore should be cleared for error ${testError}` 1267 ); 1268 1269 sandbox.restore(); 1270 } 1271 }); 1272 1273 /** 1274 * Tests changing the status prefs to ensure that backup is cleaned up if being disabled. 1275 */ 1276 add_task(async function test_changing_prefs_cleanup() { 1277 let sandbox = sinon.createSandbox(); 1278 Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, true); 1279 let bs = new BackupService(); 1280 bs.initStatusObservers(); 1281 let cleanupStub = sandbox.stub(bs, "cleanupBackupFiles"); 1282 let statusUpdatePromise = TestUtils.topicObserved( 1283 "backup-service-status-updated" 1284 ); 1285 Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, false); 1286 await statusUpdatePromise; 1287 1288 Assert.equal( 1289 cleanupStub.callCount, 1290 1, 1291 "Cleanup backup files was called on pref change" 1292 ); 1293 1294 Services.prefs.setBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME, true); 1295 1296 Assert.equal( 1297 cleanupStub.callCount, 1298 1, 1299 "Cleanup backup files should not have been called when enabling backups" 1300 ); 1301 1302 Services.prefs.clearUserPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME); 1303 }); 1304 1305 add_task(function test_checkOsSupportsBackup_win10() { 1306 const osParams = { 1307 name: "Windows_NT", 1308 version: "10.0", 1309 build: "20000", 1310 }; 1311 const result = BackupService.checkOsSupportsBackup(osParams); 1312 Assert.ok(result); 1313 }); 1314 1315 add_task(function test_checkOsSupportsBackup_win11() { 1316 const osParams = { 1317 name: "Windows_NT", 1318 version: "10.0", 1319 build: "22000", 1320 }; 1321 const result = BackupService.checkOsSupportsBackup(osParams); 1322 Assert.ok(!result); 1323 }); 1324 1325 add_task(function test_checkOsSupportsBackup_linux() { 1326 const osParams = { 1327 name: "Linux", 1328 version: "10.0", 1329 build: "22000", 1330 }; 1331 const result = BackupService.checkOsSupportsBackup(osParams); 1332 Assert.ok(!result); 1333 });