Source code
Revision control
Copy as Markdown
Other Tools
Test Info: Warnings
- This test gets skipped with pattern: os == 'linux' && os_version == '22.04' && arch == 'x86_64' && display == 'wayland'
- This test runs only with pattern: buildapp == 'browser'
- Manifest: browser/components/backup/tests/marionette/manifest.toml
# 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/.
"""
Tests that backups created with older manifest versions can still be
recovered by the current version of Firefox.
This ensures backward compatibility as the backup schema evolves.
To add a new version:
1. Before bumping SCHEMA_VERSION, run test_generate_backup_fixture.py to create the fixtures
2. Add entry to VERSION_CONFIG in compat_config.py
3. Implement any new _verify_* methods for extra_checks
4. Add test methods: test_recover_vN_backup_selectable / _legacy
"""
import os
import sys
import tempfile
from pathlib import Path
import mozfile
sys.path.append(os.fspath(Path(__file__).parents[0]))
from backup_test_base import BackupTestBase
from compat_config import VERSION_CONFIG
class BackupCompatibilityTest(BackupTestBase):
"""Test backward compatibility of backup recovery across schema versions."""
def test_recover_v1_backup_selectable(self):
"""Test that a v1 backup can be recovered into a selectable profile environment."""
self.logger.info("=== Test: V1 Backup -> Selectable ===")
self._test_recover_backup_selectable(1)
def test_recover_v1_backup_legacy(self):
"""Test that a v1 backup can be recovered into a legacy profile environment."""
self.logger.info("=== Test: V1 Backup -> Legacy ===")
self._test_recover_backup_legacy(1)
def _test_recover_backup_selectable(self, version):
"""Test recovering a backup into a selectable profile environment."""
config = VERSION_CONFIG[version]
backup_file = config["selectable_backup_file"] or config["legacy_backup_file"]
backup_path = self._get_backup_path(backup_file)
self.assertTrue(
os.path.exists(backup_path),
f"V{version} backup fixture must exist at {backup_path}",
)
self.logger.info("Step 1: Setting up selectable profile environment")
profile_name = self.register_profile_and_restart()
self._cleanups.append({"profile_name": profile_name})
selectable_info = self.setup_selectable_profile()
original_store_id = selectable_info["store_id"]
self.assertIsNotNone(original_store_id, "storeID should be set")
self.logger.info(f"Recovery environment storeID: {original_store_id}")
self.logger.info(f"Step 2: Recovering v{version} backup")
self._recovery_path = os.path.join(
tempfile.gettempdir(), f"v{version}-compat-selectable-recovery"
)
mozfile.remove(self._recovery_path)
self._cleanups.append({"path": self._recovery_path})
result = self._recover_backup(
backup_path, self._recovery_path, config["recovery_password"]
)
self._new_profile_path = result["path"]
self._new_profile_id = result["id"]
self._cleanups.append({"path": self._new_profile_path})
self.logger.info(
f"Recovery complete. New profile path: {self._new_profile_path}"
)
self.logger.info("Step 3: Launching recovered profile and verifying data")
self.marionette.quit()
intermediate_profile = self.marionette.instance.profile
self.marionette.instance.profile = self._new_profile_path
self.marionette.start_session()
self.marionette.set_context("chrome")
self.wait_for_post_recovery()
self.init_selectable_profile_service()
store_id = self.get_store_id()
self.assertEqual(
store_id,
original_store_id,
"Recovered profile should have the same storeID as profile group",
)
self.logger.info(f"Verified storeID matches: {store_id}")
self._verify_common_data(version)
for check in config["extra_checks_selectable"]:
verify_method = getattr(self, f"_verify_{check}")
verify_method(version)
self.logger.info("Step 4: Cleaning up")
self.marionette.quit()
self.marionette.instance.profile = intermediate_profile
self.marionette.start_session()
self.marionette.set_context("chrome")
self.cleanup_selectable_profiles()
self.logger.info(f"=== Test: V{version} Backup -> Selectable PASSED ===")
def _test_recover_backup_legacy(self, version):
"""Test recovering a backup into a legacy profile environment."""
config = VERSION_CONFIG[version]
backup_path = self._get_backup_path(config["legacy_backup_file"])
self.assertTrue(
os.path.exists(backup_path),
f"V{version} backup fixture must exist at {backup_path}",
)
self.logger.info("Step 1: Setting up legacy profile environment")
profile_name = self.register_profile_and_restart()
self._cleanups.append({"profile_name": profile_name})
self.set_prefs({
"browser.profiles.enabled": True,
"browser.profiles.created": False,
})
has_selectable = self.has_selectable_profiles()
self.assertFalse(has_selectable, "Should start as legacy profile")
self.logger.info("Verified profile is legacy")
self.logger.info(f"Step 2: Recovering v{version} backup")
self._recovery_path = os.path.join(
tempfile.gettempdir(), f"v{version}-compat-legacy-recovery"
)
mozfile.remove(self._recovery_path)
self._cleanups.append({"path": self._recovery_path})
result = self._recover_backup(
backup_path, self._recovery_path, config["recovery_password"]
)
self._new_profile_path = result["path"]
self._new_profile_id = result["id"]
self._cleanups.append({"path": self._new_profile_path})
self.logger.info(
f"Recovery complete. New profile path: {self._new_profile_path}"
)
self.logger.info("Step 3: Verifying legacy profile was converted to selectable")
has_selectable_after = self.has_selectable_profiles()
self.assertTrue(
has_selectable_after,
"Legacy profile should be converted to selectable after recovery",
)
self.logger.info("Legacy profile converted to selectable")
self.logger.info("Step 4: Launching recovered profile and verifying data")
self.marionette.quit()
intermediate_profile = self.marionette.instance.profile
self.marionette.instance.profile = self._new_profile_path
self.marionette.start_session()
self.marionette.set_context("chrome")
self.wait_for_post_recovery()
self.init_selectable_profile_service()
self._verify_common_data(version)
for check in config["extra_checks_legacy"]:
verify_method = getattr(self, f"_verify_{check}")
verify_method(version)
self.logger.info("Step 5: Cleaning up")
self.marionette.quit()
self.marionette.instance.profile = intermediate_profile
self.marionette.start_session()
self.marionette.set_context("chrome")
self.cleanup_selectable_profiles()
self.logger.info(f"=== Test: V{version} Backup -> Legacy PASSED ===")
def _verify_common_data(self, version):
"""Verify data that should exist in all backup versions."""
prefix = f"v{version}-test"
self._verify_login(f"https://{prefix}.example.com")
self._verify_bookmark(f"https://{prefix}.example.com/bookmark")
self._verify_history(f"https://{prefix}.example.com/history")
self._verify_preference(f"test.v{version}.compatibility.pref")
def _get_backup_path(self, filename):
"""Get path to a backup fixture."""
test_dir = os.path.dirname(__file__)
return os.path.join(test_dir, "compat-files", "backups", filename)
def _recover_backup(
self,
archive_path,
recovery_path,
recovery_password,
replace_current_profile=False,
):
"""Recover from an encrypted backup archive."""
return self.run_async(
"""
const { OSKeyStore } = ChromeUtils.importESModule(
"resource://gre/modules/OSKeyStore.sys.mjs"
);
const { BackupService } = ChromeUtils.importESModule(
"resource:///modules/backup/BackupService.sys.mjs"
);
let [archivePath, recoveryCode, recoveryPath, replaceCurrentProfile] = arguments;
// Use a fake OSKeyStore label to avoid keychain auth prompts
OSKeyStore.STORE_LABEL = "test-" + Math.random().toString(36).substr(2);
let bs = BackupService.init();
let newProfileRoot = await IOUtils.createUniqueDirectory(
PathUtils.tempDir, "backupCompatTest"
);
let profile = await bs.recoverFromBackupArchive(
archivePath, recoveryCode, false, recoveryPath, newProfileRoot, replaceCurrentProfile
);
let rootDir = await profile.rootDir;
return { name: profile.name, path: rootDir.path, id: profile.id };
""",
script_args=[
archive_path,
recovery_password,
recovery_path,
replace_current_profile,
],
)
def _verify_login(self, origin):
"""Verify a login exists for the given origin."""
count = self.marionette.execute_async_script(
"""
let [origin, outerResolve] = arguments;
(async () => {
let logins = await Services.logins.searchLoginsAsync({
origin: origin,
});
return logins.length;
})().then(outerResolve);
""",
script_args=[origin],
)
self.assertEqual(count, 1, f"Login for {origin} should exist")
def _verify_bookmark(self, url):
"""Verify a bookmark exists for the given URL."""
exists = self.marionette.execute_async_script(
"""
const { PlacesUtils } = ChromeUtils.importESModule(
"resource://gre/modules/PlacesUtils.sys.mjs"
);
let [url, outerResolve] = arguments;
(async () => {
let bookmark = await PlacesUtils.bookmarks.fetch({ url });
return bookmark != null;
})().then(outerResolve);
""",
script_args=[url],
)
self.assertTrue(exists, f"Bookmark for {url} should exist")
def _verify_history(self, url):
"""Verify a history entry exists for the given URL."""
exists = self.marionette.execute_async_script(
"""
const { PlacesUtils } = ChromeUtils.importESModule(
"resource://gre/modules/PlacesUtils.sys.mjs"
);
let [url, outerResolve] = arguments;
(async () => {
let entry = await PlacesUtils.history.fetch(url);
return entry != null;
})().then(outerResolve);
""",
script_args=[url],
)
self.assertTrue(exists, f"History for {url} should exist")
def _verify_preference(self, pref_name):
"""Verify a preference exists and is true."""
value = self.marionette.execute_script(
"""
let [prefName] = arguments;
return Services.prefs.getBoolPref(prefName, false);
""",
script_args=[pref_name],
)
self.assertTrue(value, f"Preference {pref_name} should be true")
def _verify_selectable_profile_metadata(self, version):
"""Verify recovered profile has expected selectable profile metadata."""
metadata = self.get_selectable_profile_metadata()
self.assertEqual(
metadata["name"],
f"V{version} Test Profile",
f"Profile name should be 'V{version} Test Profile'",
)
self.assertEqual(
metadata["avatar"],
"book",
"Profile avatar should be 'book'",
)
self.assertIsNotNone(
metadata["theme"],
"Profile should have a theme object",
)
self.logger.info(
f"Verified selectable profile metadata: name={metadata['name']}, "
f"avatar={metadata['avatar']}"
)