From eac29c4b1e769c34e2fddf36b08f08f827c4fe6b Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:33 +0200 Subject: [PATCH] gh-15085: Import mochitests for remote settings (gh-15086) --- .gitattributes | 2 +- .../xpcshell/runxpcshelltests-py.patch | 14 + .../ZenSpaceRoutingManager.sys.mjs | 2 +- src/zen/tests/manifest.toml | 25 + src/zen/tests/mochitests/moz.build | 3 + .../remote-settings/head_settings.js | 86 + .../test_attachments_downloader.js | 896 +++++++ .../65650a0f-7c22-4c10-9744-2d67e301f5f4.pem | 26 + .../error-bucket--bad-zip.zip | 1 + .../main--some-collection.zip | Bin 0 -> 994 bytes .../dump-collection/filename-of-dump.txt | 1 + .../filename-of-dump.txt.meta.json | 10 + .../filename-without-content.txt.meta.json | 8 + .../dump-collection/filename-without-meta.txt | 1 + .../remote-settings/test_remote_settings.js | 1798 ++++++++++++++ .../test_remote_settings_dump_lastmodified.js | 55 + .../test_remote_settings_jexl_filters.js | 210 ++ .../test_remote_settings_offline.js | 139 ++ .../test_remote_settings_older_than_local.js | 149 ++ .../test_remote_settings_poll.js | 1287 ++++++++++ .../test_remote_settings_recover_broken.js | 138 ++ .../test_remote_settings_release_prefs.js | 252 ++ .../test_remote_settings_signatures.js | 952 ++++++++ .../collection_signing_ee.pem | 16 + .../collection_signing_ee.pem.certspec | 5 + .../collection_signing_int.pem | 19 + .../collection_signing_int.pem.certspec | 4 + .../test_remote_settings_startup_bundle.js | 152 ++ .../startup.json.mozlz4 | Bin 0 -> 167191 bytes .../test_remote_settings_sync_history.js | 65 + .../test_remote_settings_utils.js | 299 +++ .../test_remote_settings_utils_telemetry.js | 79 + .../test_remote_settings_worker.js | 117 + .../remote-settings/test_shutdown_handling.js | 132 + .../remote-settings/test_uptake_telemetry.js | 68 + .../mochitests/remote-settings/xpcshell.toml | 50 + .../services-crypto/head_helpers.js | 68 + .../services-crypto/test_crypto_crypt.js | 226 ++ .../services-crypto/test_crypto_random.js | 52 + .../services-crypto/test_jwcrypto.js | 51 + .../services-crypto/test_utils_hawk.js | 346 +++ .../services-crypto/test_utils_httpmac.js | 73 + .../mochitests/services-crypto/xpcshell.toml | 20 + .../tests/mochitests/sync/addon1-search.json | 21 + .../mochitests/sync/bootstrap1-search.json | 21 + src/zen/tests/mochitests/sync/head_appinfo.js | 58 + .../sync/head_errorhandler_common.js | 195 ++ src/zen/tests/mochitests/sync/head_helpers.js | 621 +++++ .../tests/mochitests/sync/head_http_server.js | 1268 ++++++++++ .../mochitests/sync/missing-sourceuri.json | 20 + .../mochitests/sync/missing-xpi-search.json | 21 + .../mochitests/sync/prefs_test_prefs_store.js | 47 + .../tests/mochitests/sync/rewrite-search.json | 21 + .../mochitests/sync/systemaddon-search.json | 21 + src/zen/tests/mochitests/sync/test_412.js | 60 + .../tests/mochitests/sync/test_addon_utils.js | 156 ++ .../mochitests/sync/test_addons_engine.js | 277 +++ .../mochitests/sync/test_addons_reconciler.js | 209 ++ .../mochitests/sync/test_addons_store.js | 933 ++++++++ .../mochitests/sync/test_addons_tracker.js | 174 ++ .../mochitests/sync/test_addons_validator.js | 65 + .../sync/test_bookmark_batch_fail.js | 25 + .../sync/test_bookmark_decline_undecline.js | 48 + .../mochitests/sync/test_bookmark_engine.js | 1508 ++++++++++++ .../mochitests/sync/test_bookmark_order.js | 586 +++++ .../test_bookmark_places_query_rewriting.js | 57 + .../mochitests/sync/test_bookmark_record.js | 64 + .../mochitests/sync/test_bookmark_store.js | 486 ++++ .../mochitests/sync/test_bookmark_tracker.js | 1264 ++++++++++ .../mochitests/sync/test_bridged_engine.js | 230 ++ .../mochitests/sync/test_clients_engine.js | 2129 +++++++++++++++++ .../mochitests/sync/test_clients_escape.js | 57 + .../sync/test_collection_getBatched.js | 187 ++ .../sync/test_collections_recovery.js | 95 + .../mochitests/sync/test_corrupt_keys.js | 248 ++ .../tests/mochitests/sync/test_declined.js | 198 ++ .../sync/test_disconnect_shutdown.js | 104 + src/zen/tests/mochitests/sync/test_engine.js | 246 ++ .../mochitests/sync/test_engine_abort.js | 79 + .../sync/test_engine_changes_during_sync.js | 611 +++++ .../mochitests/sync/test_enginemanager.js | 232 ++ .../mochitests/sync/test_errorhandler_1.js | 335 +++ .../mochitests/sync/test_errorhandler_2.js | 529 ++++ .../sync/test_errorhandler_filelog.js | 473 ++++ ...test_errorhandler_sync_checkServerError.js | 294 +++ .../sync/test_extension_storage_engine.js | 259 ++ .../test_extension_storage_engine_kinto.js | 136 ++ .../test_extension_storage_tracker_kinto.js | 44 + .../mochitests/sync/test_form_validator.js | 86 + .../tests/mochitests/sync/test_forms_store.js | 176 ++ .../mochitests/sync/test_forms_tracker.js | 78 + .../sync/test_fxa_node_reassignment.js | 399 +++ .../sync/test_fxa_service_cluster.js | 58 + src/zen/tests/mochitests/sync/test_glean.js | 1387 +++++++++++ .../mochitests/sync/test_history_engine.js | 429 ++++ .../mochitests/sync/test_history_store.js | 560 +++++ .../mochitests/sync/test_history_tracker.js | 251 ++ .../tests/mochitests/sync/test_hmac_error.js | 250 ++ .../mochitests/sync/test_httpd_sync_server.js | 250 ++ .../mochitests/sync/test_interval_triggers.js | 472 ++++ src/zen/tests/mochitests/sync/test_keys.js | 242 ++ .../mochitests/sync/test_load_modules.js | 59 + .../mochitests/sync/test_node_reassignment.js | 523 ++++ .../mochitests/sync/test_password_engine.js | 1258 ++++++++++ .../mochitests/sync/test_password_store.js | 399 +++ .../mochitests/sync/test_password_tracker.js | 249 ++ .../sync/test_password_validator.js | 237 ++ .../tests/mochitests/sync/test_postqueue.js | 985 ++++++++ .../mochitests/sync/test_prefs_engine.js | 134 ++ .../tests/mochitests/sync/test_prefs_store.js | 402 ++++ .../mochitests/sync/test_prefs_tracker.js | 108 + .../mochitests/sync/test_records_crypto.js | 189 ++ .../tests/mochitests/sync/test_records_wbo.js | 119 + .../tests/mochitests/sync/test_resource.js | 554 +++++ .../mochitests/sync/test_resource_header.js | 63 + .../tests/mochitests/sync/test_resource_ua.js | 96 + .../test_rust_password_engine_mp_locked.js | 115 + .../test_rust_password_engine_selection.js | 64 + .../sync/test_rust_password_tracker.js | 93 + .../mochitests/sync/test_score_triggers.js | 151 ++ .../sync/test_service_attributes.js | 203 ++ .../mochitests/sync/test_service_cluster.js | 61 + .../sync/test_service_detect_upgrade.js | 274 +++ .../mochitests/sync/test_service_login.js | 224 ++ .../mochitests/sync/test_service_startOver.js | 91 + .../mochitests/sync/test_service_startup.js | 60 + .../mochitests/sync/test_service_sync_401.js | 90 + .../sync/test_service_sync_locked.js | 47 + .../sync/test_service_sync_remoteSetup.js | 241 ++ .../sync/test_service_sync_specified.js | 150 ++ .../test_service_sync_updateEnabledEngines.js | 587 +++++ .../sync/test_service_verifyLogin.js | 115 + .../sync/test_service_wipeClient.js | 78 + .../sync/test_service_wipeServer.js | 240 ++ src/zen/tests/mochitests/sync/test_status.js | 83 + .../mochitests/sync/test_status_checkSetup.js | 26 + .../mochitests/sync/test_sync_auth_manager.js | 810 +++++++ .../tests/mochitests/sync/test_syncedtabs.js | 342 +++ .../tests/mochitests/sync/test_syncengine.js | 329 +++ .../mochitests/sync/test_syncengine_sync.js | 1811 ++++++++++++++ .../mochitests/sync/test_syncscheduler.js | 1207 ++++++++++ .../tests/mochitests/sync/test_tab_engine.js | 195 ++ .../mochitests/sync/test_tab_provider.js | 59 + .../mochitests/sync/test_tab_quickwrite.js | 220 ++ .../tests/mochitests/sync/test_tab_tracker.js | 371 +++ .../tests/mochitests/sync/test_telemetry.js | 1274 ++++++++++ .../sync/test_tracker_addChanged.js | 59 + src/zen/tests/mochitests/sync/test_uistate.js | 411 ++++ .../tests/mochitests/sync/test_utils_catch.js | 119 + .../mochitests/sync/test_utils_deepEquals.js | 51 + .../mochitests/sync/test_utils_deferGetSet.js | 50 + .../tests/mochitests/sync/test_utils_json.js | 95 + .../mochitests/sync/test_utils_keyEncoding.js | 23 + .../tests/mochitests/sync/test_utils_lock.js | 76 + .../mochitests/sync/test_utils_makeGUID.js | 44 + .../mochitests/sync/test_utils_notify.js | 97 + .../mochitests/sync/test_utils_passphrase.js | 45 + src/zen/tests/mochitests/sync/xpcshell.toml | 369 +++ 158 files changed, 43670 insertions(+), 2 deletions(-) create mode 100644 src/testing/xpcshell/runxpcshelltests-py.patch create mode 100644 src/zen/tests/mochitests/remote-settings/head_settings.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/error-bucket--bad-zip.zip create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/main--some-collection.zip create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/settings/dump-bucket/dump-collection/filename-of-dump.txt create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/settings/dump-bucket/dump-collection/filename-of-dump.txt.meta.json create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/settings/dump-bucket/dump-collection/filename-without-content.txt.meta.json create mode 100644 src/zen/tests/mochitests/remote-settings/test_attachments_downloader/settings/dump-bucket/dump-collection/filename-without-meta.txt create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_dump_lastmodified.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_jexl_filters.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_offline.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_older_than_local.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_poll.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_recover_broken.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_release_prefs.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem.certspec create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem.certspec create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle/startup.json.mozlz4 create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_sync_history.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_utils.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_utils_telemetry.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_remote_settings_worker.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_shutdown_handling.js create mode 100644 src/zen/tests/mochitests/remote-settings/test_uptake_telemetry.js create mode 100644 src/zen/tests/mochitests/remote-settings/xpcshell.toml create mode 100644 src/zen/tests/mochitests/services-crypto/head_helpers.js create mode 100644 src/zen/tests/mochitests/services-crypto/test_crypto_crypt.js create mode 100644 src/zen/tests/mochitests/services-crypto/test_crypto_random.js create mode 100644 src/zen/tests/mochitests/services-crypto/test_jwcrypto.js create mode 100644 src/zen/tests/mochitests/services-crypto/test_utils_hawk.js create mode 100644 src/zen/tests/mochitests/services-crypto/test_utils_httpmac.js create mode 100644 src/zen/tests/mochitests/services-crypto/xpcshell.toml create mode 100644 src/zen/tests/mochitests/sync/addon1-search.json create mode 100644 src/zen/tests/mochitests/sync/bootstrap1-search.json create mode 100644 src/zen/tests/mochitests/sync/head_appinfo.js create mode 100644 src/zen/tests/mochitests/sync/head_errorhandler_common.js create mode 100644 src/zen/tests/mochitests/sync/head_helpers.js create mode 100644 src/zen/tests/mochitests/sync/head_http_server.js create mode 100644 src/zen/tests/mochitests/sync/missing-sourceuri.json create mode 100644 src/zen/tests/mochitests/sync/missing-xpi-search.json create mode 100644 src/zen/tests/mochitests/sync/prefs_test_prefs_store.js create mode 100644 src/zen/tests/mochitests/sync/rewrite-search.json create mode 100644 src/zen/tests/mochitests/sync/systemaddon-search.json create mode 100644 src/zen/tests/mochitests/sync/test_412.js create mode 100644 src/zen/tests/mochitests/sync/test_addon_utils.js create mode 100644 src/zen/tests/mochitests/sync/test_addons_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_addons_reconciler.js create mode 100644 src/zen/tests/mochitests/sync/test_addons_store.js create mode 100644 src/zen/tests/mochitests/sync/test_addons_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_addons_validator.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_batch_fail.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_decline_undecline.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_order.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_places_query_rewriting.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_record.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_store.js create mode 100644 src/zen/tests/mochitests/sync/test_bookmark_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_bridged_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_clients_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_clients_escape.js create mode 100644 src/zen/tests/mochitests/sync/test_collection_getBatched.js create mode 100644 src/zen/tests/mochitests/sync/test_collections_recovery.js create mode 100644 src/zen/tests/mochitests/sync/test_corrupt_keys.js create mode 100644 src/zen/tests/mochitests/sync/test_declined.js create mode 100644 src/zen/tests/mochitests/sync/test_disconnect_shutdown.js create mode 100644 src/zen/tests/mochitests/sync/test_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_engine_abort.js create mode 100644 src/zen/tests/mochitests/sync/test_engine_changes_during_sync.js create mode 100644 src/zen/tests/mochitests/sync/test_enginemanager.js create mode 100644 src/zen/tests/mochitests/sync/test_errorhandler_1.js create mode 100644 src/zen/tests/mochitests/sync/test_errorhandler_2.js create mode 100644 src/zen/tests/mochitests/sync/test_errorhandler_filelog.js create mode 100644 src/zen/tests/mochitests/sync/test_errorhandler_sync_checkServerError.js create mode 100644 src/zen/tests/mochitests/sync/test_extension_storage_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_extension_storage_engine_kinto.js create mode 100644 src/zen/tests/mochitests/sync/test_extension_storage_tracker_kinto.js create mode 100644 src/zen/tests/mochitests/sync/test_form_validator.js create mode 100644 src/zen/tests/mochitests/sync/test_forms_store.js create mode 100644 src/zen/tests/mochitests/sync/test_forms_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_fxa_node_reassignment.js create mode 100644 src/zen/tests/mochitests/sync/test_fxa_service_cluster.js create mode 100644 src/zen/tests/mochitests/sync/test_glean.js create mode 100644 src/zen/tests/mochitests/sync/test_history_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_history_store.js create mode 100644 src/zen/tests/mochitests/sync/test_history_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_hmac_error.js create mode 100644 src/zen/tests/mochitests/sync/test_httpd_sync_server.js create mode 100644 src/zen/tests/mochitests/sync/test_interval_triggers.js create mode 100644 src/zen/tests/mochitests/sync/test_keys.js create mode 100644 src/zen/tests/mochitests/sync/test_load_modules.js create mode 100644 src/zen/tests/mochitests/sync/test_node_reassignment.js create mode 100644 src/zen/tests/mochitests/sync/test_password_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_password_store.js create mode 100644 src/zen/tests/mochitests/sync/test_password_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_password_validator.js create mode 100644 src/zen/tests/mochitests/sync/test_postqueue.js create mode 100644 src/zen/tests/mochitests/sync/test_prefs_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_prefs_store.js create mode 100644 src/zen/tests/mochitests/sync/test_prefs_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_records_crypto.js create mode 100644 src/zen/tests/mochitests/sync/test_records_wbo.js create mode 100644 src/zen/tests/mochitests/sync/test_resource.js create mode 100644 src/zen/tests/mochitests/sync/test_resource_header.js create mode 100644 src/zen/tests/mochitests/sync/test_resource_ua.js create mode 100644 src/zen/tests/mochitests/sync/test_rust_password_engine_mp_locked.js create mode 100644 src/zen/tests/mochitests/sync/test_rust_password_engine_selection.js create mode 100644 src/zen/tests/mochitests/sync/test_rust_password_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_score_triggers.js create mode 100644 src/zen/tests/mochitests/sync/test_service_attributes.js create mode 100644 src/zen/tests/mochitests/sync/test_service_cluster.js create mode 100644 src/zen/tests/mochitests/sync/test_service_detect_upgrade.js create mode 100644 src/zen/tests/mochitests/sync/test_service_login.js create mode 100644 src/zen/tests/mochitests/sync/test_service_startOver.js create mode 100644 src/zen/tests/mochitests/sync/test_service_startup.js create mode 100644 src/zen/tests/mochitests/sync/test_service_sync_401.js create mode 100644 src/zen/tests/mochitests/sync/test_service_sync_locked.js create mode 100644 src/zen/tests/mochitests/sync/test_service_sync_remoteSetup.js create mode 100644 src/zen/tests/mochitests/sync/test_service_sync_specified.js create mode 100644 src/zen/tests/mochitests/sync/test_service_sync_updateEnabledEngines.js create mode 100644 src/zen/tests/mochitests/sync/test_service_verifyLogin.js create mode 100644 src/zen/tests/mochitests/sync/test_service_wipeClient.js create mode 100644 src/zen/tests/mochitests/sync/test_service_wipeServer.js create mode 100644 src/zen/tests/mochitests/sync/test_status.js create mode 100644 src/zen/tests/mochitests/sync/test_status_checkSetup.js create mode 100644 src/zen/tests/mochitests/sync/test_sync_auth_manager.js create mode 100644 src/zen/tests/mochitests/sync/test_syncedtabs.js create mode 100644 src/zen/tests/mochitests/sync/test_syncengine.js create mode 100644 src/zen/tests/mochitests/sync/test_syncengine_sync.js create mode 100644 src/zen/tests/mochitests/sync/test_syncscheduler.js create mode 100644 src/zen/tests/mochitests/sync/test_tab_engine.js create mode 100644 src/zen/tests/mochitests/sync/test_tab_provider.js create mode 100644 src/zen/tests/mochitests/sync/test_tab_quickwrite.js create mode 100644 src/zen/tests/mochitests/sync/test_tab_tracker.js create mode 100644 src/zen/tests/mochitests/sync/test_telemetry.js create mode 100644 src/zen/tests/mochitests/sync/test_tracker_addChanged.js create mode 100644 src/zen/tests/mochitests/sync/test_uistate.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_catch.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_deepEquals.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_deferGetSet.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_json.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_keyEncoding.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_lock.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_makeGUID.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_notify.js create mode 100644 src/zen/tests/mochitests/sync/test_utils_passphrase.js create mode 100644 src/zen/tests/mochitests/sync/xpcshell.toml diff --git a/.gitattributes b/.gitattributes index 42b01886c..d18643924 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,4 @@ *.patch linguist-language=C++ *.d.ts linguist-language=TypeScript -src/zen/tests/*.js linguist-language=Test +src/zen/tests/* linguist-language=C++ diff --git a/src/testing/xpcshell/runxpcshelltests-py.patch b/src/testing/xpcshell/runxpcshelltests-py.patch new file mode 100644 index 000000000..c0aaeea1b --- /dev/null +++ b/src/testing/xpcshell/runxpcshelltests-py.patch @@ -0,0 +1,14 @@ +diff --git a/testing/xpcshell/runxpcshelltests.py b/testing/xpcshell/runxpcshelltests.py +index 403401613189ffb758037850dc6bbb1d55f194ab..cad28e245155eb54775e551885532026e56c5fa9 100755 +--- a/testing/xpcshell/runxpcshelltests.py ++++ b/testing/xpcshell/runxpcshelltests.py +@@ -2275,6 +2275,9 @@ class XPCShellTests: + appDirKey = None + if "appname" in self.mozInfo: + appDirKey = self.mozInfo["appname"] + "-appdir" ++ if appDirKey == "zen-appdir": ++ # Inherited manifests use firefox-appdir. ++ appDirKey = "firefox-appdir" + + # We have to do this before we run tests that depend on having the node + # http/2 server. diff --git a/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs b/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs index 7622417d8..0a0c70185 100644 --- a/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs +++ b/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs @@ -27,7 +27,7 @@ class nsZenSpaceRoutingManager { const element = window.MozXULElement.parseXULToFragment(` `); window.document.getElementById("context_undoCloseTab").after(element); diff --git a/src/zen/tests/manifest.toml b/src/zen/tests/manifest.toml index c3f444157..d242dd5ba 100644 --- a/src/zen/tests/manifest.toml +++ b/src/zen/tests/manifest.toml @@ -13,10 +13,26 @@ disable = [ "browser_readerMode_colorSchemePref.js", ] +[remote-settings] +source = "services/settings/test/unit" +is_direct_path = true +xpcshell = true + +[remote-settings.replace-manifest] +"../../../" = "../../../../services/" + [safebrowsing] source = "browser/components/safebrowsing/content/test" is_direct_path = true +[services-crypto] +source = "services/crypto/tests/unit" +is_direct_path = true +xpcshell = true + +[services-crypto.replace-manifest] +"../../../" = "../../../../services/" + [shell] source = "browser/components/shell/test" is_direct_path = true @@ -25,6 +41,15 @@ disable = [ "browser_setDesktopBackgroundPreview.js", ] +[sync] +source = "services/sync/tests/unit" +is_direct_path = true +xpcshell = true + +[sync.replace-manifest] +"../../../" = "../../../../services/" +'"identity.fxaccounts.enabled=true"' = '"identity.fxaccounts.enabled=true", "browser.formfill.enable=true"' + [tabMediaIndicator] source = "browser/components/tabbrowser/test/browser/tabMediaIndicator" is_direct_path = true diff --git a/src/zen/tests/mochitests/moz.build b/src/zen/tests/mochitests/moz.build index bbf85269c..d798a5e2e 100644 --- a/src/zen/tests/mochitests/moz.build +++ b/src/zen/tests/mochitests/moz.build @@ -15,4 +15,7 @@ BROWSER_CHROME_MANIFESTS += [ "tooltiptext/browser.toml", ] XPCSHELL_TESTS_MANIFESTS += [ + "remote-settings/xpcshell.toml", + "services-crypto/xpcshell.toml", + "sync/xpcshell.toml", ] \ No newline at end of file diff --git a/src/zen/tests/mochitests/remote-settings/head_settings.js b/src/zen/tests/mochitests/remote-settings/head_settings.js new file mode 100644 index 000000000..f575f276c --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/head_settings.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../../common/tests/unit/head_global.js */ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + Database: "resource://services-settings/Database.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + RemoteSettingsClient: + "resource://services-settings/RemoteSettingsClient.sys.mjs", + RemoteSettingsWorker: + "resource://services-settings/RemoteSettingsWorker.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + SharedUtils: "resource://services-settings/SharedUtils.sys.mjs", + SyncHistory: "resource://services-settings/SyncHistory.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UptakeTelemetry: "resource://services-settings/UptakeTelemetry.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", +}); + +const IS_ANDROID = AppConstants.platform == "android"; + +function arrayEqual(a, b) { + return JSON.stringify(a) == JSON.stringify(b); +} + +add_setup(function () { + Services.fog.initializeFOG(); +}); + +function enableUptakeMetric() { + Services.fog.applyServerKnobsConfig( + JSON.stringify({ + metrics_enabled: { + "uptake.remotecontent.result.uptake_remotesettings": true, + }, + }) + ); +} + +function assertTelemetryEvents(expectedEvents) { + if (IS_ANDROID) { + /* + Since `Services.fog.applyServerKnobsConfig()` has no effect on Android + https://searchfox.org/firefox-main/rev/e28b34ab33dbf49364999070168cbb7e11e8e5bd/toolkit/components/glean/xpcom/FOG.cpp#379-390 + we skip assertions on events on this platform. + Alternatively we could have disabled the whole tests suites or introduced `if IS_ANDROID` on each + test involving telemetry, but this seems the most reasonable way. + See Bug 2028765 and Bug 2042499 + */ + Assert.ok( + true, + "Glean Uptake Telemetry assertions are ALWAYS true on Android" + ); + return; + } + const events = + Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue() ?? []; + const receivedValues = events.map(e => e.extra.value); + Assert.equal( + events.length, + expectedEvents.length, + `number of uptake events (${receivedValues})` + ); + for (let i = 0; i < expectedEvents.length; i++) { + for (const [key, expected] of Object.entries(expectedEvents[i])) { + if (typeof expected === "function") { + Assert.ok( + expected(events[i].extra[key]), + `event[${i}].extra.${key} passes validator` + ); + } else { + Assert.equal( + events[i].extra[key], + expected, + `event[${i}].extra.${key}` + ); + } + } + } +} diff --git a/src/zen/tests/mochitests/remote-settings/test_attachments_downloader.js b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader.js new file mode 100644 index 000000000..a2d1ca070 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader.js @@ -0,0 +1,896 @@ +const { Downloader } = ChromeUtils.importESModule( + "resource://services-settings/Attachments.sys.mjs" +); + +const RECORD = { + id: "1f3a0802-648d-11ea-bd79-876a8b69c377", + attachment: { + hash: "f41ed47d0f43325c9f089d03415c972ce1d3f1ecab6e4d6260665baf3db3ccee", + size: 1597, + filename: "test_file.pem", + location: + "main-workspace/some-collection/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem", + mimetype: "application/x-pem-file", + }, +}; + +const RECORD_OF_DUMP = { + id: "filename-of-dump.txt", + attachment: { + filename: "filename-of-dump.txt", + hash: "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b", + size: 25, + }, + last_modified: 1234567, + some_key: "some metadata", +}; + +let downloader; +let server; + +add_setup(() => { + server = new HttpServer(); + server.start(-1); + registerCleanupFunction(() => server.stop(() => {})); + + server.registerDirectory( + "/cdn/main-workspace/some-collection/", + do_get_file("test_attachments_downloader") + ); + server.registerDirectory( + "/cdn/bundles/", + do_get_file("test_attachments_downloader") + ); + + // For this test, we are using a server other than production. Force + // LOAD_DUMPS to true so that we can still load attachments from dumps. + delete Utils.LOAD_DUMPS; + Utils.LOAD_DUMPS = true; +}); + +async function clear_state() { + Services.fog.testResetFOG(); + enableUptakeMetric(); + + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v1` + ); + + downloader = new Downloader("main", "some-collection"); + downloader.cache = {}; + const memCacheImpl = { + get: async id => { + return downloader.cache[id]; + }, + set: async (id, obj) => { + downloader.cache[id] = obj; + }, + setMultiple: async idsObjs => { + idsObjs.forEach(([id, obj]) => (downloader.cache[id] = obj)); + }, + delete: async id => { + delete downloader.cache[id]; + }, + deleteMultiple: async ids => { + ids.forEach(id => delete downloader.cache[id]); + }, + hasData: async () => { + return !!Object.keys(downloader.cache).length; + }, + }; + // The download() method requires a cacheImpl, but the Downloader + // class does not have one. Define a dummy no-op one. + Object.defineProperty(downloader, "cacheImpl", { + value: memCacheImpl, + // Writable to allow specific tests to override cacheImpl. + writable: true, + }); + await downloader.deleteDownloaded(RECORD); + + server.registerPathHandler("/v1/", (request, response) => { + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: `http://localhost:${server.identity.primaryPort}/cdn/`, + }, + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + + // For tests that use a real client and DB cache, clear the local DB too. + const client = RemoteSettings("some-collection"); + await client.db.clear(); + await client.db.pruneAttachments([]); +} +add_task(clear_state); + +add_task( + async function test_download_throws_server_info_error_if_invalid_response() { + server.registerPathHandler("/v1/", (request, response) => { + response.write("{bad json content"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + + let error; + try { + await downloader.download(RECORD); + } catch (e) { + error = e; + } + + Assert.ok(error instanceof Downloader.ServerInfoError); + } +); +add_task(clear_state); + +add_task(async function test_download_is_retried_3_times_if_download_fails() { + const record = { + id: "abc", + attachment: { + ...RECORD.attachment, + location: "404-error.pem", + }, + }; + + let called = 0; + const _fetchAttachment = downloader._fetchAttachment; + downloader._fetchAttachment = async url => { + called++; + return _fetchAttachment(url); + }; + + let error; + try { + await downloader.download(record); + } catch (e) { + error = e; + } + + Assert.equal(called, 4); // 1 + 3 retries + Assert.ok(error instanceof Downloader.DownloadError); +}); +add_task(clear_state); + +add_task(async function test_download_as_bytes() { + const bytes = await downloader.downloadAsBytes(RECORD); + + // See *.pem file in tests data. + Assert.greater( + bytes.byteLength, + 1500, + `Wrong bytes size: ${bytes.byteLength}` + ); +}); +add_task(clear_state); + +add_task(async function test_download_is_retried_3_times_if_content_fails() { + const record = { + id: "abc", + attachment: { + ...RECORD.attachment, + hash: "always-wrong", + }, + }; + let called = 0; + downloader._fetchAttachment = async () => { + called++; + return new ArrayBuffer(); + }; + + let error; + try { + await downloader.download(record); + } catch (e) { + error = e; + } + + Assert.equal(called, 4); // 1 + 3 retries + Assert.ok(error instanceof Downloader.BadContentError); +}); +add_task(clear_state); + +add_task(async function test_delete_all() { + const client = RemoteSettings("some-collection"); + await client.db.create(RECORD); + await downloader.download(RECORD); + + await client.attachments.deleteAll(); + + Assert.ok(!(await client.attachments.cacheImpl.get(RECORD.id))); +}); +add_task(clear_state); + +add_task(async function test_downloader_reports_download_errors() { + const client = RemoteSettings("some-collection"); + + const record = { + attachment: { + ...RECORD.attachment, + location: "404-error.pem", + }, + }; + + try { + await client.attachments.download(record, { retry: 0 }); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.DOWNLOAD_START, + source: client.identifier, + }, + { + value: UptakeTelemetry.STATUS.DOWNLOAD_ERROR, + source: client.identifier, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_downloader_reports_offline_error() { + const backupOffline = Services.io.offline; + Services.io.offline = true; + + try { + const client = RemoteSettings("some-collection"); + const record = { + attachment: { + ...RECORD.attachment, + location: "will-try-and-fail.pem", + }, + }; + try { + await client.attachments.download(record, { retry: 0 }); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.DOWNLOAD_START, + source: client.identifier, + }, + { + value: UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR, + source: client.identifier, + }, + ]); + } finally { + Services.io.offline = backupOffline; + } +}); +add_task(clear_state); + +// Common code for test_download_cache_hit and test_download_cache_corruption. +async function doTestDownloadCacheImpl({ + simulateCorruption, + expectedReads = 1, + expectedWrites = 1, + downloadOptions = {}, +}) { + let readCount = 0; + let writeCount = 0; + const cacheImpl = { + async get(attachmentId) { + Assert.equal(attachmentId, RECORD.id, "expected attachmentId"); + ++readCount; + if (simulateCorruption) { + throw new Error("Simulation of corrupted cache (read)"); + } + }, + async set(attachmentId, attachment) { + Assert.equal(attachmentId, RECORD.id, "expected attachmentId"); + Assert.deepEqual(attachment.record, RECORD, "expected record"); + ++writeCount; + if (simulateCorruption) { + throw new Error("Simulation of corrupted cache (write)"); + } + }, + async delete() {}, + }; + Object.defineProperty(downloader, "cacheImpl", { value: cacheImpl }); + + let downloadResult = await downloader.download(RECORD, downloadOptions); + Assert.equal(downloadResult._source, "remote_match", "expected source"); + Assert.equal(downloadResult.buffer.byteLength, 1597, "expected result"); + Assert.equal(readCount, expectedReads, "expected cache read attempts"); + Assert.equal(writeCount, expectedWrites, "expected cache write attempts"); +} + +add_task(async function test_download_cache_hit() { + await doTestDownloadCacheImpl({ simulateCorruption: false }); +}); +add_task(clear_state); + +// Verify that the downloader works despite a broken cache implementation. +add_task(async function test_download_cache_corruption() { + await doTestDownloadCacheImpl({ simulateCorruption: true }); +}); +add_task(clear_state); + +add_task(async function test_download_with_cache_enabled() { + await doTestDownloadCacheImpl({ + simulateCorruption: false, + downloadOptions: { + cacheResult: true, + }, + }); +}); +add_task(clear_state); + +add_task(async function test_download_with_cache_disabled() { + await doTestDownloadCacheImpl({ + simulateCorruption: false, + expectedWrites: 0, + downloadOptions: { + cacheResult: false, + }, + }); +}); +add_task(clear_state); + +add_task(async function test_download_cached() { + const client = RemoteSettings("main", "some-collection"); + const attachmentId = "dummy filename"; + const badRecord = { + attachment: { + ...RECORD.attachment, + hash: "non-matching hash", + location: "non-existing-location-should-fail.bin", + }, + }; + async function downloadWithCache(record, options) { + options = { ...options, useCache: true }; + return client.attachments.download(record, options); + } + function checkInfo(downloadResult, expectedSource, msg) { + Assert.deepEqual( + downloadResult.record, + RECORD, + `${msg} : expected identical record` + ); + // Simple check: assume that content is identical if the size matches. + Assert.equal( + downloadResult.buffer.byteLength, + RECORD.attachment.size, + `${msg} : expected buffer` + ); + Assert.equal( + downloadResult._source, + expectedSource, + `${msg} : expected source of the result` + ); + } + + await Assert.rejects( + downloadWithCache(null, { attachmentId }), + /DownloadError: Could not download dummy filename/, + "Download without record or cache should fail." + ); + + // Populate cache. + const info1 = await downloadWithCache(RECORD, { attachmentId }); + checkInfo(info1, "remote_match", "first time download"); + + await Assert.rejects( + downloadWithCache(null, { attachmentId }), + /DownloadError: Could not download dummy filename/, + "Download without record still fails even if there is a cache." + ); + + await Assert.rejects( + downloadWithCache(badRecord, { attachmentId }), + /DownloadError: Could not download .*non-existing-location-should-fail.bin/, + "Download with non-matching record still fails even if there is a cache." + ); + + // Download from cache. + const info2 = await downloadWithCache(RECORD, { attachmentId }); + checkInfo(info2, "cache_match", "download matching record from cache"); + + const info3 = await downloadWithCache(RECORD, { + attachmentId, + fallbackToCache: true, + }); + checkInfo(info3, "cache_match", "fallbackToCache accepts matching record"); + + const info4 = await downloadWithCache(null, { + attachmentId, + fallbackToCache: true, + }); + checkInfo(info4, "cache_fallback", "fallbackToCache accepts null record"); + + const info5 = await downloadWithCache(badRecord, { + attachmentId, + fallbackToCache: true, + }); + checkInfo(info5, "cache_fallback", "fallbackToCache ignores bad record"); + + // Bye bye cache. + await client.attachments.deleteDownloaded({ id: attachmentId }); + await Assert.rejects( + downloadWithCache(null, { attachmentId, fallbackToCache: true }), + /DownloadError: Could not download dummy filename/, + "Download without cache should fail again." + ); + await Assert.rejects( + downloadWithCache(badRecord, { attachmentId, fallbackToCache: true }), + /DownloadError: Could not download .*non-existing-location-should-fail.bin/, + "Download should fail to fall back to a download of a non-existing record" + ); +}); +add_task(clear_state); + +add_task(async function test_download_from_dump() { + const client = RemoteSettings("dump-collection", { + bucketName: "dump-bucket", + }); + + // Temporarily replace the resource:-URL with another resource:-URL. + const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL; + Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test"; + const resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution( + "rs-downloader-test", + Services.io.newFileURI(do_get_file("test_attachments_downloader")) + ); + + function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) { + Assert.equal( + new TextDecoder().decode(new Uint8Array(result.buffer)), + "This would be a RS dump.\n", + "expected content from dump" + ); + Assert.deepEqual(result.record, expectedRecord, "expected record for dump"); + Assert.equal(result._source, expectedSource, "expected source of dump"); + } + + // If record matches, should happen before network request. + const dump1 = await client.attachments.download(RECORD_OF_DUMP, { + // Note: attachmentId not set, so should fall back to record.id. + fallbackToDump: true, + }); + checkInfo(dump1, "dump_match"); + + // If no record given, should try network first, but then fall back to dump. + const dump2 = await client.attachments.download(null, { + attachmentId: RECORD_OF_DUMP.id, + fallbackToDump: true, + }); + checkInfo(dump2, "dump_fallback"); + + // Fill the cache with the same data as the dump for the next part. + await client.db.saveAttachment(RECORD_OF_DUMP.id, { + record: RECORD_OF_DUMP, + blob: new Blob([dump1.buffer]), + }); + // The dump should take precedence over the cache. + const dump3 = await client.attachments.download(RECORD_OF_DUMP, { + fallbackToCache: true, + fallbackToDump: true, + }); + checkInfo(dump3, "dump_match"); + + // When the record is not given, the dump takes precedence over the cache + // as a fallback (when the cache and dump are identical). + const dump4 = await client.attachments.download(null, { + attachmentId: RECORD_OF_DUMP.id, + fallbackToCache: true, + fallbackToDump: true, + }); + checkInfo(dump4, "dump_fallback"); + + // Store a record in the cache that is newer than the dump. + const RECORD_NEWER_THAN_DUMP = { + ...RECORD_OF_DUMP, + last_modified: RECORD_OF_DUMP.last_modified + 1, + }; + await client.db.saveAttachment(RECORD_OF_DUMP.id, { + record: RECORD_NEWER_THAN_DUMP, + blob: new Blob([dump1.buffer]), + }); + + // When the record is not given, use the cache if it has a more recent record. + const dump5 = await client.attachments.download(null, { + attachmentId: RECORD_OF_DUMP.id, + fallbackToCache: true, + fallbackToDump: true, + }); + checkInfo(dump5, "cache_fallback", RECORD_NEWER_THAN_DUMP); + + // When a record is given, use whichever that has the matching last_modified. + const dump6 = await client.attachments.download(RECORD_OF_DUMP, { + fallbackToCache: true, + fallbackToDump: true, + }); + checkInfo(dump6, "dump_match"); + const dump7 = await client.attachments.download(RECORD_NEWER_THAN_DUMP, { + fallbackToCache: true, + fallbackToDump: true, + }); + checkInfo(dump7, "cache_match", RECORD_NEWER_THAN_DUMP); + + await client.attachments.deleteDownloaded(RECORD_OF_DUMP); + + await Assert.rejects( + client.attachments.download(null, { + attachmentId: "filename-without-meta.txt", + fallbackToDump: true, + }), + /DownloadError: Could not download filename-without-meta.txt/, + "Cannot download dump that lacks a .meta.json file" + ); + + await Assert.rejects( + client.attachments.download(null, { + attachmentId: "filename-without-content.txt", + fallbackToDump: true, + }), + /Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/, + "Cannot download dump that is missing, despite the existing .meta.json" + ); + + // Restore, just in case. + Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL; + resProto.setSubstitution("rs-downloader-test", null); +}); +// Not really needed because the last test doesn't modify the main collection, +// but added for consistency with other tests tasks around here. +add_task(clear_state); + +add_task( + async function test_download_from_dump_fails_when_load_dumps_is_false() { + const client = RemoteSettings("dump-collection", { + bucketName: "dump-bucket", + }); + + // Temporarily replace the resource:-URL with another resource:-URL. + const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL; + Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test"; + const resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution( + "rs-downloader-test", + Services.io.newFileURI(do_get_file("test_attachments_downloader")) + ); + + function checkInfo( + result, + expectedSource, + expectedRecord = RECORD_OF_DUMP + ) { + Assert.equal( + new TextDecoder().decode(new Uint8Array(result.buffer)), + "This would be a RS dump.\n", + "expected content from dump" + ); + Assert.deepEqual( + result.record, + expectedRecord, + "expected record for dump" + ); + Assert.equal(result._source, expectedSource, "expected source of dump"); + } + + // Download the dump so that we can use it to fill the cache. + const dump1 = await client.attachments.download(RECORD_OF_DUMP, { + // Note: attachmentId not set, so should fall back to record.id. + fallbackToDump: true, + }); + checkInfo(dump1, "dump_match"); + + // Fill the cache with the same data as the dump for the next part. + await client.db.saveAttachment(RECORD_OF_DUMP.id, { + record: RECORD_OF_DUMP, + blob: new Blob([dump1.buffer]), + }); + + // Now turn off loading dumps, and check we no longer load from the dump, + // but use the cache instead. + Utils.LOAD_DUMPS = false; + + const dump2 = await client.attachments.download(RECORD_OF_DUMP, { + // Note: attachmentId not set, so should fall back to record.id. + fallbackToDump: true, + }); + checkInfo(dump2, "cache_match"); + + // When the record is not given, the dump would take precedence over the + // cache but we have disabled dumps, so we should load from the cache. + const dump4 = await client.attachments.download(null, { + attachmentId: RECORD_OF_DUMP.id, + fallbackToCache: true, + fallbackToDump: true, + }); + checkInfo(dump4, "cache_fallback"); + + // Restore, just in case. + Utils.LOAD_DUMPS = true; + Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL; + resProto.setSubstitution("rs-downloader-test", null); + } +); + +add_task(async function test_attachment_get() { + // Since get() is largely a wrapper around the same code as download(), + // we only test a couple of parts to check it functions as expected, and + // rely on the download() testing for the rest. + + await Assert.rejects( + downloader.get(RECORD), + /NotFoundError: Could not find /, + "get() fails when there is no local cache nor dump" + ); + + const client = RemoteSettings("dump-collection", { + bucketName: "dump-bucket", + }); + + // Temporarily replace the resource:-URL with another resource:-URL. + const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL; + Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test"; + const resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution( + "rs-downloader-test", + Services.io.newFileURI(do_get_file("test_attachments_downloader")) + ); + + function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) { + Assert.equal( + new TextDecoder().decode(new Uint8Array(result.buffer)), + "This would be a RS dump.\n", + "expected content from dump" + ); + Assert.deepEqual(result.record, expectedRecord, "expected record for dump"); + Assert.equal(result._source, expectedSource, "expected source of dump"); + } + + // When a record is given, use whichever that has the matching last_modified. + const dump = await client.attachments.get(RECORD_OF_DUMP); + checkInfo(dump, "dump_match"); + + await client.attachments.deleteDownloaded(RECORD_OF_DUMP); + + await Assert.rejects( + client.attachments.get(null, { + attachmentId: "filename-without-meta.txt", + fallbackToDump: true, + }), + /NotFoundError: Could not find filename-without-meta.txt in cache or dump/, + "Cannot download dump that lacks a .meta.json file" + ); + + await Assert.rejects( + client.attachments.get(null, { + attachmentId: "filename-without-content.txt", + fallbackToDump: true, + }), + /Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/, + "Cannot download dump that is missing, despite the existing .meta.json" + ); + + // Restore, just in case. + Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL; + resProto.setSubstitution("rs-downloader-test", null); +}); +// Not really needed because the last test doesn't modify the main collection, +// but added for consistency with other tests tasks around here. +add_task(clear_state); + +add_task(async function test_obsolete_attachments_are_pruned() { + const RECORD2 = { + ...RECORD, + id: "another-id", + }; + const client = RemoteSettings("some-collection"); + // Store records and related attachments directly in the cache. + await client.db.importChanges({}, 42, [RECORD, RECORD2], { clear: true }); + await client.db.saveAttachment(RECORD.id, { + record: RECORD, + blob: new Blob(["123"]), + }); + await client.db.saveAttachment("custom-id", { + record: RECORD2, + blob: new Blob(["456"]), + }); + // Store an extraneous cached attachment. + await client.db.saveAttachment("bar", { + record: { id: "bar" }, + blob: new Blob(["789"]), + }); + + const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id); + Assert.equal( + await recordAttachment.blob.text(), + "123", + "Record has a cached attachment" + ); + const record2Attachment = await client.attachments.cacheImpl.get("custom-id"); + Assert.equal( + await record2Attachment.blob.text(), + "456", + "Record 2 has a cached attachment" + ); + const { blob: cachedExtra } = await client.attachments.cacheImpl.get("bar"); + Assert.equal(await cachedExtra.text(), "789", "There is an extra attachment"); + + await client.attachments.prune([]); + + Assert.ok( + await client.attachments.cacheImpl.get(RECORD.id), + "Record attachment was kept" + ); + Assert.ok( + await client.attachments.cacheImpl.get("custom-id"), + "Record 2 attachment was kept" + ); + Assert.ok( + !(await client.attachments.cacheImpl.get("bar")), + "Extra was deleted" + ); +}); +add_task(clear_state); + +add_task( + async function test_obsolete_attachments_listed_as_excluded_are_not_pruned() { + const client = RemoteSettings("some-collection"); + // Store records and related attachments directly in the cache. + await client.db.importChanges({}, 42, [], { clear: true }); + await client.db.saveAttachment(RECORD.id, { + record: RECORD, + blob: new Blob(["123"]), + }); + + const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id); + Assert.equal( + await recordAttachment.blob.text(), + "123", + "Record has a cached attachment" + ); + + await client.attachments.prune([RECORD.id]); + + Assert.ok( + await client.attachments.cacheImpl.get(RECORD.id), + "Record attachment was kept" + ); + } +); + +add_task(clear_state); + +add_task(async function test_cacheAll_happy_path() { + // verify bundle is downloaded succesfully + const allSuccess = await downloader.cacheAll(); + Assert.ok( + allSuccess, + "Attachments cacheAll succesfully downloaded a bundle and saved all attachments" + ); + + // verify accuracy of attachments downloaded + Assert.equal( + downloader.cache["1"].record.title, + "test1", + "Test record 1 meta content appears accurate." + ); + Assert.equal( + await downloader.cache["1"].blob.text(), + "test1\n", + "Test file 1 content is accurate." + ); + Assert.equal( + downloader.cache["2"].record.title, + "test2", + "Test record 2 meta content appears accurate." + ); + Assert.equal( + await downloader.cache["2"].blob.text(), + "test2\n", + "Test file 2 content is accurate." + ); +}); + +add_task(async function test_cacheAll_using_real_db() { + const client = RemoteSettings("some-collection"); + + const allSuccess = await client.attachments.cacheAll(); + + Assert.ok( + allSuccess, + "Attachments cacheAll succesfully downloaded a bundle and saved all attachments" + ); + + Assert.equal( + (await client.attachments.cacheImpl.get("2")).record.title, + "test2", + "Test record 2 meta content appears accurate." + ); + Assert.equal( + await (await client.attachments.cacheImpl.get("2")).blob.text(), + "test2\n", + "Test file 2 content is accurate." + ); +}); + +add_task(clear_state); + +add_task(async function test_cacheAll_skips_with_existing_data() { + downloader.cache = { + 1: "1", + }; + const allSuccess = await downloader.cacheAll(); + Assert.equal( + allSuccess, + null, + "Attachments cacheAll skips downloads if data already exists" + ); +}); + +add_task(async function test_cacheAll_does_not_skip_if_force_is_true() { + downloader.cache = { + 1: "1", + }; + const allSuccess = await downloader.cacheAll(true); + Assert.equal( + allSuccess, + true, + "Attachments cacheAll does not skip downloads if force is true" + ); +}); + +add_task(clear_state); + +add_task(async function test_cacheAll_failed_request() { + downloader.bucketName = "fake-bucket"; + downloader.collectionName = "fake-collection"; + const allSuccess = await downloader.cacheAll(); + Assert.equal( + allSuccess, + false, + "Attachments cacheAll request failed to download a bundle and returned false" + ); +}); + +add_task(clear_state); + +add_task(async function test_cacheAll_failed_unzip() { + downloader.bucketName = "error-bucket"; + downloader.collectionName = "bad-zip"; + const allSuccess = await downloader.cacheAll(); + Assert.equal( + allSuccess, + false, + "Attachments cacheAll request failed to extract a bundle and returned false" + ); +}); + +add_task(clear_state); + +add_task(async function test_cacheAll_failed_save() { + const client = RemoteSettings("some-collection"); + + const backup = client.db.saveAttachments; + client.db.saveAttachments = () => { + throw new Error("boom"); + }; + + const allSuccess = await client.attachments.cacheAll(); + + Assert.equal( + allSuccess, + false, + "Attachments cacheAll failed to save entries in DB and returned false" + ); + client.db.saveAttachments = backup; +}); + +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem new file mode 100644 index 000000000..502e8c9ce --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEbjCCA1agAwIBAgIQBg3WwdBnkBtUdfz/wp4xNzANBgkqhkiG9w0BAQsFADBa +MQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJl +clRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTE1 +MTAxNDEyMDAwMFoXDTIwMTAxNDEyMDAwMFowbzELMAkGA1UEBhMCVVMxCzAJBgNV +BAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRkwFwYDVQQKExBDbG91ZEZs +YXJlLCBJbmMuMSAwHgYDVQQDExdDbG91ZEZsYXJlIEluYyBSU0EgQ0EtMTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJGiNOIE4s0M4wdhDeV9aMfAYY9l +yG9cfGQqt7a5UgrRA81bi4istCyhzfzRWUW+NAmf6X2HEnA3xLI1M+pH/xEbk9pw +jc8/1CPy9jUjBwb89zt5PWh2I1KxZVg/Bnx2yYdVcKTUMKt0GLDXfZXN+RYZHJQo +lDlzjH5xV0IpDMv/FsMEZWcfx1JorBf08bRnRVkl9RY00y2ujVr+492ze+zYQ9s7 +HcidpR+7ret3jzLSvojsaA5+fOaCG0ctVJcLfnkQ5lWR95ByBdO1NapfqZ1+kmCL +3baVSeUpYQriBwznxfLuGs8POo4QdviYVtSPBWjOEfb+o1c6Mbo8p4noFzUCAwEA +AaOCARkwggEVMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMDQG +CCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu +Y29tMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9P +bW5pcm9vdDIwMjUuY3JsMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB +FhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMB0GA1UdDgQWBBSRBYrfTCLG +bYuUTBZFfu5vAvu3wDAfBgNVHSMEGDAWgBTlnVkwgkdYzKz6CFQ2hns6tQRN8DAN +BgkqhkiG9w0BAQsFAAOCAQEAVJle3ar9NSnTrLAhgfkcpClIY6/kabDIEa8cOnu1 +SOXf4vbtZakSmmIbFbmYDUGIU5XwwVdF/FKNzNBRf9G4EL/S0NXytBKj4A34UGQA +InaV+DgVLzCifN9cAHi8EFEAfbglUvPvLPFXF0bwffElYm7QBSiHYSZmfOKLCyiv +3zlQsf7ozNBAxfbmnRMRSUBcIhRwnaFoFgDs7yU6R1Yk4pO7eMgWpdPGhymDTIvv +RnauKStzKsAli9i5hQ4nTDITUpMAmeJoXodgwRkC3Civw32UR2rxObIyxPpbfODb +sZKNGO9K5Sjj6turB1zwbd2wI8MhtUCY9tGmSYhe7G6Bkw== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/error-bucket--bad-zip.zip b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/error-bucket--bad-zip.zip new file mode 100644 index 000000000..5b07782c9 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/error-bucket--bad-zip.zip @@ -0,0 +1 @@ +not a zip diff --git a/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/main--some-collection.zip b/src/zen/tests/mochitests/remote-settings/test_attachments_downloader/main--some-collection.zip new file mode 100644 index 0000000000000000000000000000000000000000..8413ee7ab9412e5d32ea3e31accfdf9cb884ab92 GIT binary patch literal 994 zcmWIWW@Zs#;9%fjF!OvF!2ktp3=9m63^p*b(?IxGNN-wc1vdjD%L`@( z1~8%RsjFxBgoz=*o1H`2JhFy^fq?;}8DXr07sA*(3=9n1AY=7%Q%e%{vWoNbz!t0s z=}kKo)SCvP(JTl(;DM+%b~?r^!DRXZEwG-nlDzr1kW^+FPcl zi<265U+iMG{bV}rh8thgnpN)=pdA zz#>wAW|iokWq;*1EtuhRL}Yrj(2~FZDxb5oTBaPh$ir^4_I+u9=e9ke(|_Fx`?@#l zR?cZVkT><#+P9W?MzT_8i>XldKV29sw6KvaQZ%LPq7o8Qc#{QAg=fOTjumdI-QK~+|=bt^rwde zNn7@1E>dK%stLZbeaV$Kj~9g5?4EN%#_;j;oEH*OHE|YU@wR)JgaZE>F8BK|Yg_e+ zo~%ur!BOiXCw#m8yjNJHY`S3eW|@x#3$Ht>-u`@b&MWWiSFPDJQ2 zGKnxFQXg^x0;N7^Vr1CT2x1XTivivU<3OnnIif(R4rB-jZ)pr?U|=vr3q?@c!;uD& z4F<(Ca#W$Z(~gmWfncm78wZMC { + // Set up an HTTP Server + server = new HttpServer(); + server.start(-1); + + // Point the blocklist clients to use this local HTTP server. + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v1` + ); + + Services.prefs.setStringPref("services.settings.loglevel", "debug"); + + client = RemoteSettings("password-fields"); + clientWithDump = RemoteSettings("language-dictionaries"); + + server.registerPathHandler("/v1/", handleResponse); + server.registerPathHandler( + "/v1/buckets/monitor/collections/changes/changeset", + handleResponse + ); + server.registerPathHandler( + "/v1/buckets/main/collections/password-fields/changeset", + handleResponse + ); + server.registerPathHandler( + "/v1/buckets/main/collections/language-dictionaries/changeset", + handleResponse + ); + server.registerPathHandler( + "/v1/buckets/main/collections/with-local-fields/changeset", + handleResponse + ); + server.registerPathHandler("/fake-x5u", handleResponse); + + registerCleanupFunction(() => { + server.stop(() => {}); + }); +}); +add_task(clear_state); + +add_task(async function test_records_obtained_from_server_are_stored_in_db() { + // Test an empty db populates + await client.maybeSync(2000); + + // Open the collection, verify it's been populated: + // Our test data has a single record; it should be in the local collection + const list = await client.get(); + equal(list.length, 1); + + const timestamp = await client.db.getLastModified(); + equal(timestamp, 3000, "timestamp was stored"); + + const { signatures } = await client.db.getMetadata(); + equal(signatures[0].signature, "abcdef", "metadata was stored"); +}); +add_task(clear_state); + +add_task(async function test_client_db_throws_if_not_synced() { + try { + await client.db.list(); + Assert.ok(false, "db.list() should throw"); + } catch (e) { + Assert.equal( + e.toString(), + 'EmptyDatabaseError: "main/password-fields" has not been synced yet' + ); + } + + await client.maybeSync(2000); + + const list = await client.db.list(); + Assert.ok(Array.isArray(list), "data is an array"); +}); +add_task(clear_state); + +add_task( + async function test_records_from_dump_are_listed_as_created_in_event() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + let received; + clientWithDump.on("sync", ({ data }) => (received = data)); + // Use a timestamp superior to latest record in dump. + const timestamp = 5000000000000; // Fri Jun 11 2128 + + await clientWithDump.maybeSync(timestamp); + + const list = await clientWithDump.get(); + Assert.greater( + list.length, + 20, + `The dump was loaded (${list.length} records)` + ); + equal(received.created[0].id, "xx", "Record from the sync come first."); + + const createdById = received.created.reduce((acc, r) => { + acc[r.id] = r; + return acc; + }, {}); + + ok( + !(received.deleted[0].id in createdById), + "Deleted records are not listed as created" + ); + equal( + createdById[received.updated[0].new.id], + received.updated[0].new, + "The records that were updated should appear as created in their newest form." + ); + + equal( + received.created.length, + list.length, + "The list of created records contains the dump" + ); + equal(received.current.length, received.created.length); + } +); +add_task(clear_state); + +add_task(async function test_throws_when_network_is_offline() { + const backupOffline = Services.io.offline; + try { + Services.io.offline = true; + let error; + try { + await clientWithDump.maybeSync(2000); + } catch (e) { + error = e; + } + equal(error.name, "NetworkOfflineError"); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR, + }, + ]); + } finally { + Services.io.offline = backupOffline; + } +}); +add_task(clear_state); + +add_task(async function test_sync_event_is_sent_even_if_up_to_date() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + // First, determine what is the dump timestamp. Sync will load it. + // Use a timestamp inferior to latest record in dump. + await clientWithDump._importJSONDump(); + const uptodateTimestamp = await clientWithDump.db.getLastModified(); + await clear_state(); + + // Now, simulate that server data wasn't changed since dump was released. + let received; + clientWithDump.on("sync", ({ data }) => (received = data)); + + await clientWithDump.maybeSync(uptodateTimestamp); + + ok(!!received.current.length, "Dump records are listed as created"); + equal(received.current.length, received.created.length); + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.UP_TO_DATE, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_records_can_have_local_fields() { + const c = RemoteSettings("with-local-fields", { localFields: ["accepted"] }); + c.verifySignature = false; + + await c.maybeSync(2000); + + await c.db.update({ + id: "c74279ce-fb0a-42a6-ae11-386b567a6119", + accepted: true, + }); + await c.maybeSync(3000); // Does not fail. +}); +add_task(clear_state); + +add_task( + async function test_records_changes_are_overwritten_by_server_changes() { + // Create some local conflicting data, and make sure it syncs without error. + await client.db.create({ + website: "", + id: "9d500963-d80e-3a91-6e74-66f3811b99cc", + }); + + await client.maybeSync(2000); + + const data = await client.get(); + equal(data[0].website, "https://some-website.com"); + } +); +add_task(clear_state); + +add_task( + async function test_get_returns_an_empty_list_when_database_is_empty() { + const data = await client.get({ syncIfEmpty: false }); + + ok(Array.isArray(data), "data is an array"); + equal(data.length, 0, "data is empty"); + } +); +add_task(clear_state); + +add_task(async function test_get_doesnt_affect_other_calls() { + const c1 = RemoteSettings("password-fields"); + const c2 = RemoteSettings("password-fields"); + + const result1 = await c1.get({ syncIfEmpty: false }); + Assert.deepEqual(result1, [], "data1 is empty"); + + try { + await c2.get({ syncIfEmpty: false, emptyListFallback: false }); + Assert.ok(false, "get() should throw"); + } catch (error) { + Assert.equal( + error.toString(), + 'EmptyDatabaseError: "main/password-fields" has not been synced yet' + ); + } +}); +add_task(clear_state); + +add_task(async function test_get_throws_if_no_empty_fallback_and_no_sync() { + try { + await client.get({ syncIfEmpty: false, emptyListFallback: false }); + Assert.ok(false, ".get() should throw"); + } catch (error) { + Assert.equal( + error.toString(), + 'EmptyDatabaseError: "main/password-fields" has not been synced yet' + ); + } +}); +add_task(clear_state); + +add_task( + async function test_get_loads_default_records_from_a_local_dump_when_database_is_empty() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + + // When collection has a dump in services/settings/dumps/{bucket}/{collection}.json + const data = await clientWithDump.get(); + notEqual(data.length, 0); + // No synchronization happened (responses are not mocked). + } +); +add_task(clear_state); + +add_task(async function test_get_loads_dump_only_once_if_called_in_parallel() { + const backup = clientWithDump._importJSONDump; + let callCount = 0; + clientWithDump._importJSONDump = async () => { + callCount++; + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + return 42; + }; + await Promise.all([clientWithDump.get(), clientWithDump.get()]); + equal(callCount, 1, "JSON dump was called more than once"); + clientWithDump._importJSONDump = backup; +}); +add_task(clear_state); + +add_task(async function test_get_falls_back_to_dump_if_db_fails() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + const backup = clientWithDump.db.getLastModified; + clientWithDump.db.getLastModified = () => { + throw new Error("Unknown error"); + }; + + const records = await clientWithDump.get({ dumpFallback: true }); + ok(!!records.length, "dump content is returned"); + + // If fallback is disabled, error is thrown. + let error; + try { + await clientWithDump.get({ dumpFallback: false }); + } catch (e) { + error = e; + } + equal(error.message, "Unknown error"); + + clientWithDump.db.getLastModified = backup; +}); +add_task(clear_state); + +add_task(async function test_get_sorts_results_if_specified() { + await client.db.importChanges( + {}, + 42, + [ + { + field: 12, + id: "9d500963-d80e-3a91-6e74-66f3811b99cc", + }, + { + field: 7, + id: "d83444a4-f348-4cd8-8228-842cb927db9f", + }, + ], + { clear: true } + ); + + const records = await client.get({ order: "field" }); + Assert.less( + records[0].field, + records[records.length - 1].field, + "records are sorted" + ); +}); +add_task(clear_state); + +add_task(async function test_get_falls_back_sorts_results() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + const backup = clientWithDump.db.getLastModified; + clientWithDump.db.getLastModified = () => { + throw new Error("Unknown error"); + }; + + const records = await clientWithDump.get({ + dumpFallback: true, + order: "-id", + }); + + // eslint-disable-next-line mozilla/no-comparison-or-assignment-inside-ok + ok(records[0].id > records[records.length - 1].id, "records are sorted"); + + clientWithDump.db.getLastModified = backup; +}); +add_task(clear_state); + +add_task(async function test_get_falls_back_to_dump_if_db_fails_later() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + const backup = clientWithDump.db.list; + clientWithDump.db.list = () => { + throw new Error("Unknown error"); + }; + + const records = await clientWithDump.get({ dumpFallback: true }); + ok(!!records.length, "dump content is returned"); + + // If fallback is disabled, error is thrown. + let error; + try { + await clientWithDump.get({ dumpFallback: false }); + } catch (e) { + error = e; + } + equal(error.message, "Unknown error"); + + clientWithDump.db.list = backup; +}); +add_task(clear_state); + +add_task(async function test_get_falls_back_to_dump_if_network_fails() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + const backup = clientWithDump.sync; + clientWithDump.sync = () => { + throw new Error("Sync error"); + }; + + const records = await clientWithDump.get(); + ok(!!records.length, "dump content is returned"); + + clientWithDump.sync = backup; +}); +add_task(clear_state); + +add_task(async function test_get_does_not_sync_if_empty_dump_is_provided() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + + const clientWithEmptyDump = RemoteSettings("example"); + Assert.ok(!(await Utils.hasLocalData(clientWithEmptyDump))); + + const data = await clientWithEmptyDump.get(); + + equal(data.length, 0); + Assert.ok(await Utils.hasLocalData(clientWithEmptyDump)); +}); +add_task(clear_state); + +add_task(async function test_get_synchronization_can_be_disabled() { + const data = await client.get({ syncIfEmpty: false }); + + equal(data.length, 0); +}); +add_task(clear_state); + +add_task( + async function test_get_triggers_synchronization_when_database_is_empty() { + // The "password-fields" collection has no local dump, and no local data. + // Therefore a synchronization will happen. + const data = await client.get(); + + // Data comes from mocked HTTP response (see below). + equal(data.length, 1); + equal(data[0].selector, "#webpage[field-pwd]"); + } +); +add_task(clear_state); + +add_task(async function test_get_ignores_synchronization_errors_by_default() { + // The monitor endpoint won't contain any information about this collection. + let data = await RemoteSettings("some-unknown-key").get(); + equal(data.length, 0); + // The sync endpoints are not mocked, this fails internally. + data = await RemoteSettings("no-mocked-responses").get(); + equal(data.length, 0); +}); +add_task(clear_state); + +add_task(async function test_get_throws_if_no_empty_fallback() { + // The monitor endpoint won't contain any information about this collection. + try { + await RemoteSettings("some-unknown-key").get({ + emptyListFallback: false, + }); + Assert.ok(false, ".get() should throw"); + } catch (error) { + Assert.ok( + error.message.includes("Response from server unparseable"), + "Server error was thrown" + ); + } +}); +add_task(clear_state); + +add_task( + async function test_get_throws_on_network_error_with_no_empty_fallback() { + const backup = Utils.fetch; + Utils.fetch = async () => { + throw new Error("Fake Network error"); + }; + + const clientEmpty = RemoteSettings("no-dump-no-local-data"); + let error; + try { + await clientEmpty.get({ + emptyListFallback: false, + syncIfEmpty: true, // default value + }); + } catch (exc) { + error = exc; + } + + equal(error.toString(), "Error: Fake Network error"); + Utils.fetch = backup; + } +); + +add_task(async function test_get_verify_signature_empty_no_sync() { + client.verifySignature = true; + // No data, hence no signature in metadata, and no sync if empty. + let error; + try { + await client.get({ + verifySignature: true, + syncIfEmpty: false, + emptyListFallback: false, + }); + Assert.ok(false, "get() should throw"); + } catch (e) { + error = e; + } + equal( + error.toString(), + 'EmptyDatabaseError: "main/password-fields" has not been synced yet' + ); +}); +add_task(clear_state); + +add_task(async function test_get_verify_signature_no_metadata_no_sync() { + client.verifySignature = true; + // Store records but no metadata. + await client.db.importChanges(undefined, 42, []); + let error; + try { + await client.get({ + verifySignature: true, + syncIfEmpty: false, + emptyListFallback: false, + }); + Assert.ok(false, "get() should throw"); + } catch (e) { + error = e; + } + equal( + error.toString(), + "MissingSignatureError: Missing signature (main/password-fields)" + ); +}); +add_task(clear_state); + +add_task(async function test_get_can_verify_signature_pulled() { + // Populate the local DB (only records, eg. loaded from dump previously) + await client._importJSONDump(); + + let calledSignature; + client._verifier = { + async asyncVerifyContentSignature(serialized, signature) { + calledSignature = signature; + return true; + }, + }; + client.verifySignature = true; + + // No metadata in local DB, but gets pulled and then verifies. + ok(ObjectUtils.isEmpty(await client.db.getMetadata()), "Metadata is empty"); + + await client.get({ verifySignature: true }); + + ok( + !ObjectUtils.isEmpty(await client.db.getMetadata()), + "Metadata was pulled" + ); + ok(calledSignature.endsWith("some-sig"), "Signature was verified"); +}); +add_task(clear_state); + +add_task(async function test_get_can_verify_signature() { + // Populate the local DB (record and metadata) + await client.maybeSync(2000); + + client.verifySignature = true; + + // It validates signature that was stored in local DB. + let calledSignature; + client._verifier = { + async asyncVerifyContentSignature(serialized, signature) { + calledSignature = signature; + return JSON.parse(serialized).data.length == 1; + }, + }; + ok(await Utils.hasLocalData(client), "Local data was populated"); + await client.get({ verifySignature: true }); + + ok(calledSignature.endsWith("abcdef"), "Signature was verified"); + + // It throws when signature does not verify. + await client.db.delete("9d500963-d80e-3a91-6e74-66f3811b99cc"); + let error = null; + try { + await client.get({ verifySignature: true }); + } catch (e) { + error = e; + } + equal( + error.message, + "Invalid content signature (main/password-fields) using 'fake-x5u' and signer remote-settings.content-signature.mozilla.org" + ); +}); +add_task(clear_state); + +add_task(async function test_get_does_not_verify_signature_if_load_dump() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + + let called; + clientWithDump._verifier = { + async asyncVerifyContentSignature() { + called = true; + return true; + }, + }; + + // When dump is loaded, signature is not verified. + const records = await clientWithDump.get({ verifySignature: true }); + ok(!!records.length, "dump is loaded"); + ok(!called, "signature is missing but not verified"); + + // If metadata is missing locally, it is not fetched if `syncIfEmpty` is disabled. + clientWithDump.verifySignature = true; + let error; + try { + await clientWithDump.get({ verifySignature: true, syncIfEmpty: false }); + } catch (e) { + error = e; + } + ok(!called, "signer was not called"); + equal( + error.message, + "Missing signature (main/language-dictionaries)", + "signature is missing locally" + ); + + // If metadata is missing locally, it is fetched by default (`syncIfEmpty: true`) + await clientWithDump.get({ verifySignature: true }); + const metadata = await clientWithDump.db.getMetadata(); + ok(!!Object.keys(metadata).length, "metadata was fetched"); + ok(called, "signature was verified for the data that was in dump"); + clientWithDump.verifySignature = true; +}); +add_task(clear_state); + +add_task( + async function test_get_does_verify_signature_if_json_loaded_in_parallel() { + const backup = clientWithDump._verifier; + let callCount = 0; + clientWithDump._verifier = { + async asyncVerifyContentSignature() { + callCount++; + return true; + }, + }; + await Promise.all([ + clientWithDump.get({ verifySignature: true }), + clientWithDump.get({ verifySignature: true }), + ]); + equal(callCount, 0, "No need to verify signatures if JSON dump is loaded"); + clientWithDump._verifier = backup; + } +); +add_task(clear_state); + +add_task(async function test_get_can_force_a_sync() { + const step0 = await client.db.getLastModified(); + await client.get({ forceSync: true }); + const step1 = await client.db.getLastModified(); + await client.get(); + const step2 = await client.db.getLastModified(); + await client.get({ forceSync: true }); + const step3 = await client.db.getLastModified(); + + equal(step0, null); + equal(step1, 3000); + equal(step2, 3000); + equal(step3, 3001); +}); +add_task(clear_state); + +add_task(async function test_sync_runs_once_only() { + const backup = Utils.log.warn; + const messages = []; + Utils.log.warn = m => { + messages.push(m); + }; + + await Promise.all([client.maybeSync(2000), client.maybeSync(2000)]); + + ok( + messages.includes("main/password-fields sync already running"), + "warning is shown about sync already running" + ); + Utils.log.warn = backup; +}); +add_task(clear_state); + +add_task( + async function test_sync_pulls_metadata_if_missing_with_dump_is_up_to_date() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + + let called; + clientWithDump._verifier = { + async asyncVerifyContentSignature() { + called = true; + return true; + }, + }; + // When dump is loaded, signature is not verified. + const records = await clientWithDump.get({ verifySignature: true }); + ok(!!records.length, "dump is loaded"); + ok(!called, "signature is missing but not verified"); + + // Synchronize the collection (local data is up-to-date). + // Signature verification is disabled (see `clear_state()`), so we don't bother with + // fetching metadata. + const uptodateTimestamp = await clientWithDump.db.getLastModified(); + await clientWithDump.maybeSync(uptodateTimestamp); + let metadata = await clientWithDump.db.getMetadata(); + ok(!metadata, "metadata was not fetched"); + + // Synchronize again the collection (up-to-date, since collection last modified still > 42) + clientWithDump.verifySignature = true; + await clientWithDump.maybeSync(42); + + // With signature verification, metadata was fetched. + metadata = await clientWithDump.db.getMetadata(); + ok(!!Object.keys(metadata).length, "metadata was fetched"); + ok(called, "signature was verified for the data that was in dump"); + + // Metadata is present, signature will now verified. + called = false; + await clientWithDump.get({ verifySignature: true }); + ok(called, "local signature is verified"); + } +); +add_task(clear_state); + +add_task(async function test_sync_event_provides_information_about_records() { + let eventData; + client.on("sync", ({ data }) => (eventData = data)); + + await client.maybeSync(2000); + equal(eventData.current.length, 1); + + await client.maybeSync(3001); + equal(eventData.current.length, 2); + equal(eventData.created.length, 1); + equal(eventData.created[0].website, "https://www.other.org/signin"); + equal(eventData.updated.length, 1); + equal(eventData.updated[0].old.website, "https://some-website.com"); + equal(eventData.updated[0].new.website, "https://some-website.com/login"); + equal(eventData.deleted.length, 0); + + await client.maybeSync(4001); + equal(eventData.current.length, 1); + equal(eventData.created.length, 0); + equal(eventData.updated.length, 0); + equal(eventData.deleted.length, 1); + equal(eventData.deleted[0].website, "https://www.other.org/signin"); +}); +add_task(clear_state); + +add_task(async function test_inspect_method() { + // Synchronize the `password-fields` collection in order to have + // some local data when .inspect() is called. + await client.maybeSync(2000); + + const inspected = await RemoteSettings.inspect(); + + // Assertion for global attributes. + const { mainBucket, serverURL, defaultSigner, collections, serverTimestamp } = + inspected; + const rsSigner = "remote-settings.content-signature.mozilla.org"; + equal(mainBucket, "main"); + equal(serverURL, `http://localhost:${server.identity.primaryPort}/v1`); + equal(defaultSigner, rsSigner); + equal(serverTimestamp, "5000"); + + // A collection is listed in .inspect() if it has local data or if there + // is a JSON dump for it. + // "password-fields" has no dump but was synchronized above and thus has local data. + let col = collections.pop(); + equal(col.collection, "password-fields"); + equal(col.serverTimestamp, 3000); + equal(col.localTimestamp, 3000); + + if (!IS_ANDROID) { + // "language-dictionaries" has a local dump (not on Android) + col = collections.pop(); + equal(col.collection, "language-dictionaries"); + equal(col.serverTimestamp, 4000); + ok(!col.localTimestamp); // not synchronized. + } +}); +add_task(clear_state); + +add_task(async function test_inspect_method_uses_a_random_cache_bust() { + const backup = Utils.fetchLatestChanges; + const cacheBusts = []; + Utils.fetchLatestChanges = (url, options) => { + cacheBusts.push(options.expected); + return { changes: [] }; + }; + + await RemoteSettings.inspect(); + await RemoteSettings.inspect(); + await RemoteSettings.inspect(); + + notEqual(cacheBusts[0], cacheBusts[1]); + notEqual(cacheBusts[1], cacheBusts[2]); + notEqual(cacheBusts[0], cacheBusts[2]); + Utils.fetchLatestChanges = backup; +}); + +add_task(async function test_jexl_context_is_shown_in_inspect() { + const { jexlContext } = await RemoteSettings.inspect(); + deepEqual(Object.keys(jexlContext).sort(), [ + "appinfo", + "channel", + "country", + "formFactor", + "locale", + "os", + "version", + ]); + deepEqual(Object.keys(jexlContext.os).sort(), ["name", "version"]); + deepEqual(Object.keys(jexlContext.appinfo).sort(), ["ID", "OS"]); +}); + +add_task(async function test_clearAll_method() { + // Make sure we have some local data. + await client.maybeSync(2000); + await clientWithDump.maybeSync(2000); + + await RemoteSettings.clearAll(); + + ok(!(await Utils.hasLocalData(client)), "Local data was deleted"); + ok(!(await Utils.hasLocalData(clientWithDump)), "Local data was deleted"); + ok( + !Services.prefs.prefHasUserValue(client.lastCheckTimePref), + "Pref was cleaned" + ); + + // Synchronization is not broken after resuming. + await client.maybeSync(2000); + await clientWithDump.maybeSync(2000); + ok(await Utils.hasLocalData(client), "Local data was populated"); + ok(await Utils.hasLocalData(clientWithDump), "Local data was populated"); +}); +add_task(clear_state); + +add_task(async function test_listeners_are_not_deduplicated() { + let count = 0; + const plus1 = () => { + count += 1; + }; + + client.on("sync", plus1); + client.on("sync", plus1); + client.on("sync", plus1); + + await client.maybeSync(2000); + + equal(count, 3); +}); +add_task(clear_state); + +add_task(async function test_listeners_can_be_removed() { + let count = 0; + const onSync = () => { + count += 1; + }; + + client.on("sync", onSync); + client.off("sync", onSync); + + await client.maybeSync(2000); + + equal(count, 0); +}); +add_task(clear_state); + +add_task(async function test_all_listeners_are_executed_if_one_fails() { + let count = 0; + client.on("sync", () => { + count += 1; + }); + client.on("sync", () => { + throw new Error("boom"); + }); + client.on("sync", () => { + count += 2; + }); + + let error; + try { + await client.maybeSync(2000); + } catch (e) { + error = e; + } + + equal(count, 3); + equal(error.message, "boom"); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_up_to_date() { + await client.maybeSync(2000); + + const expectedTelemetry = [ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + ]; + assertTelemetryEvents(expectedTelemetry); + + await client.maybeSync(3000); + + // No Telemetry was sent. + assertTelemetryEvents( + expectedTelemetry.concat([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.UP_TO_DATE, + }, + ]) + ); +}); +add_task(clear_state); + +add_task(async function test_telemetry_if_sync_succeeds() { + // We test each client because Telemetry requires preleminary declarations. + await client.maybeSync(2000); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + ]); +}); +add_task(clear_state); + +add_task( + async function test_synchronization_duration_is_reported_in_uptake_status() { + await client.maybeSync(2000); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + source: client.identifier, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: client.identifier, + trigger: "manual", + duration: d => parseInt(d) > 0, + }, + ]); + } +); +add_task(clear_state); + +add_task(async function test_telemetry_reports_if_application_fails() { + client.on("sync", () => { + throw new Error("boom"); + }); + + try { + await client.maybeSync(2000); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.APPLY_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_if_sync_fails() { + await client.db.importChanges({}, 9999); + + try { + await client.maybeSync(10000); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.SERVER_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_if_parsing_fails() { + await client.db.importChanges({}, 10000); + + try { + await client.maybeSync(10001); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.PARSE_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_if_fetching_signature_fails() { + await client.db.importChanges({}, 11000); + + try { + await client.maybeSync(11001); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.SERVER_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_unknown_errors() { + const backup = client.db.getLastModified; + client.db.getLastModified = () => { + throw new Error("Internal"); + }; + + try { + await client.maybeSync(2000); + } catch (e) {} + + client.db.getLastModified = backup; + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.UNKNOWN_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_indexeddb_as_custom_1() { + const backup = client.db.getLastModified; + const msg = + "IndexedDB getLastModified() The operation failed for reasons unrelated to the database itself"; + client.db.getLastModified = () => { + throw new Error(msg); + }; + + try { + await client.maybeSync(2000); + } catch (e) {} + + client.db.getLastModified = backup; + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + }, + { + value: UptakeTelemetry.STATUS.CUSTOM_1_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_telemetry_reports_error_name_as_event_nightly() { + const backup = client.db.getLastModified; + client.db.getLastModified = () => { + const e = new Error("Some unknown error"); + e.name = "ThrownError"; + throw e; + }; + + try { + await client.maybeSync(2000); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + source: client.identifier, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.UNKNOWN_ERROR, + source: client.identifier, + trigger: "manual", + errorName: "ThrownError", + duration: d => parseInt(d) >= 0, + }, + ]); + + client.db.getLastModified = backup; +}); +add_task(clear_state); + +add_task(async function test_bucketname_changes_when_preview_mode_is_enabled() { + equal(client.bucketName, "main"); + + RemoteSettings.enablePreviewMode(true); + + equal(client.bucketName, "main-preview"); +}); +add_task(clear_state); + +add_task( + async function test_preview_mode_pref_affects_bucket_names_before_instantiated() { + Services.prefs.setBoolPref("services.settings.preview_enabled", true); + + let clientWithDefaultBucket = RemoteSettings("other"); + let clientWithBucket = RemoteSettings("coll", { bucketName: "buck" }); + + equal(clientWithDefaultBucket.bucketName, "main-preview"); + equal(clientWithBucket.bucketName, "buck-preview"); + } +); +add_task(clear_state); + +add_task( + async function test_preview_enabled_pref_ignored_when_mode_is_set_explicitly() { + Services.prefs.setBoolPref("services.settings.preview_enabled", true); + + let clientWithDefaultBucket = RemoteSettings("other"); + let clientWithBucket = RemoteSettings("coll", { bucketName: "buck" }); + + equal(clientWithDefaultBucket.bucketName, "main-preview"); + equal(clientWithBucket.bucketName, "buck-preview"); + + RemoteSettings.enablePreviewMode(false); + + equal(clientWithDefaultBucket.bucketName, "main"); + equal(clientWithBucket.bucketName, "buck"); + } +); +add_task(clear_state); + +add_task( + async function test_get_loads_default_records_from_a_local_dump_when_preview_mode_is_enabled() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + RemoteSettings.enablePreviewMode(true); + // When collection has a dump in services/settings/dumps/{bucket}/{collection}.json + const data = await clientWithDump.get(); + notEqual(data.length, 0); + // No synchronization happened (responses are not mocked). + } +); +add_task(clear_state); + +add_task(async function test_local_db_distinguishes_preview_records() { + RemoteSettings.enablePreviewMode(true); + client.db.importChanges({}, Date.now(), [{ id: "record-1" }], { + clear: true, + }); + + RemoteSettings.enablePreviewMode(false); + client.db.importChanges({}, Date.now(), [{ id: "record-2" }], { + clear: true, + }); + + deepEqual(await client.get(), [{ id: "record-2" }]); +}); +add_task(clear_state); + +add_task( + async function test_inspect_changes_the_list_when_preview_mode_is_enabled() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest), + // and this test relies on the fact that clients are instantiated if a dump is packaged. + return; + } + + // Register a client only listed in -preview... + RemoteSettings("crash-rate"); + + const { collections: before, previewMode: previewModeBefore } = + await RemoteSettings.inspect(); + + Assert.ok(!previewModeBefore, "preview is not enabled"); + + // These two collections are listed in the main bucket in monitor/changes (one with dump, one registered). + deepEqual(before.map(c => c.collection).sort(), [ + "language-dictionaries", + "password-fields", + ]); + + // Switch to preview mode. + RemoteSettings.enablePreviewMode(true); + + const { + collections: after, + mainBucket, + previewMode, + } = await RemoteSettings.inspect(); + + Assert.ok(previewMode, "preview is enabled"); + + // These two collections are listed in the main bucket in monitor/changes (both are registered). + deepEqual(after.map(c => c.collection).sort(), [ + "crash-rate", + "password-fields", + ]); + equal(mainBucket, "main-preview"); + } +); +add_task(clear_state); + +add_task(async function test_sync_event_is_not_sent_from_get_when_no_dump() { + let called = false; + client.on("sync", () => { + called = true; + }); + + await client.get(); + + Assert.ok(!called, "sync event is not sent from .get()"); +}); +add_task(clear_state); + +add_task(async function test_get_can_be_called_from_sync_event_callback() { + let fromGet; + let fromEvent; + + client.on("sync", async ({ data: { current } }) => { + // Before fixing Bug 1761953 this would result in a deadlock. + fromGet = await client.get(); + fromEvent = current; + }); + + await client.maybeSync(2000); + + Assert.ok(fromGet, "sync callback was called"); + Assert.deepEqual(fromGet, fromEvent, ".get() gives current records list"); +}); +add_task(clear_state); + +add_task(async function test_attachments_are_pruned_when_sync_from_timer() { + await client.db.saveAttachment("bar", { + record: { id: "bar" }, + blob: new Blob(["456"]), + }); + + await client.maybeSync(2000, { trigger: "broadcast" }); + + Assert.ok( + await client.attachments.cacheImpl.get("bar"), + "Extra attachment was not deleted on broadcast" + ); + + await client.maybeSync(3001, { trigger: "timer" }); + + Assert.ok( + !(await client.attachments.cacheImpl.get("bar")), + "Extra attachment was deleted on timer" + ); +}); +add_task(clear_state); + +function handleResponse(request, response) { + try { + const sample = getSampleResponse(request, server.identity.primaryPort); + if (!sample) { + do_throw( + `unexpected ${request.method} request for ${request.path}?${request.queryString}` + ); + } + + response.setStatusLine( + null, + sample.status.status, + sample.status.statusText + ); + // send the headers + for (let headerLine of sample.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + response.setHeader("Date", new Date().toUTCString()); + + const body = + typeof sample.responseBody == "string" + ? sample.responseBody + : JSON.stringify(sample.responseBody); + response.write(body); + response.finish(); + } catch (e) { + info(e); + } +} + +function getSampleResponse(req, port) { + const responses = { + OPTIONS: { + sampleHeaders: [ + "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page", + "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS", + "Access-Control-Allow-Origin: *", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: null, + }, + "GET:/v1/": { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: { + settings: { + batch_max_requests: 25, + }, + url: `http://localhost:${port}/v1/`, + documentation: "https://kinto.readthedocs.org/", + version: "1.5.1", + commit: "cbc6f58", + hello: "kinto", + }, + }, + "GET:/v1/buckets/monitor/collections/changes/changeset": { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + `Date: ${new Date().toUTCString()}`, + 'Etag: "5000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: { + timestamp: 5000, + changes: [ + { + id: "4676f0c7-9757-4796-a0e8-b40a5a37a9c9", + bucket: "main", + collection: "unknown-locally", + last_modified: 5000, + }, + { + id: "4676f0c7-9757-4796-a0e8-b40a5a37a9c9", + bucket: "main", + collection: "language-dictionaries", + last_modified: 4000, + }, + { + id: "0af8da0b-3e03-48fb-8d0d-2d8e4cb7514d", + bucket: "main", + collection: "password-fields", + last_modified: 3000, + }, + { + id: "4acda969-3bd3-4074-a678-ff311eeb076e", + bucket: "main-preview", + collection: "password-fields", + last_modified: 2000, + }, + { + id: "58697bd1-315f-4185-9bee-3371befc2585", + bucket: "main-preview", + collection: "crash-rate", + last_modified: 1000, + }, + ], + }, + }, + "GET:/fake-x5u": { + sampleHeaders: ["Content-Type: application/octet-stream"], + status: { status: 200, statusText: "OK" }, + responseBody: `-----BEGIN CERTIFICATE----- +MIIGYTCCBEmgAwIBAgIBATANBgkqhkiG9w0BAQwFADB9MQswCQYDVQQGEwJVU +ZARKjbu1TuYQHf0fs+GwID8zeLc2zJL7UzcHFwwQ6Nda9OJN4uPAuC/BKaIpxCLL +26b24/tRam4SJjqpiq20lynhUrmTtt6hbG3E1Hpy3bmkt2DYnuMFwEx2gfXNcnbT +wNuvFqc= +-----END CERTIFICATE-----`, + }, + "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=2000": + { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + 'Etag: "3000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: { + timestamp: 3000, + metadata: { + id: "password-fields", + last_modified: 1234, + signatures: [ + { + signature: "abcdef", + x5u: `http://localhost:${port}/fake-x5u`, + }, + ], + }, + changes: [ + { + id: "9d500963-d80e-3a91-6e74-66f3811b99cc", + last_modified: 3000, + website: "https://some-website.com", + selector: "#user[password]", + }, + ], + }, + }, + "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=3001&_since=3000": + { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + 'Etag: "4000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: { + metadata: { + signatures: [{}], + }, + timestamp: 4000, + changes: [ + { + id: "aabad965-e556-ffe7-4191-074f5dee3df3", + last_modified: 4000, + website: "https://www.other.org/signin", + selector: "#signinpassword", + }, + { + id: "9d500963-d80e-3a91-6e74-66f3811b99cc", + last_modified: 3500, + website: "https://some-website.com/login", + selector: "input#user[password]", + }, + ], + }, + }, + "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=4001&_since=4000": + { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + 'Etag: "5000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: { + metadata: { + signatures: [{}], + }, + timestamp: 5000, + changes: [ + { + id: "aabad965-e556-ffe7-4191-074f5dee3df3", + deleted: true, + }, + ], + }, + }, + "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=10000&_since=9999": + { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 503, statusText: "Service Unavailable" }, + responseBody: { + code: 503, + errno: 999, + error: "Service Unavailable", + }, + }, + "GET:/v1/buckets/main/collections/password-fields/changeset?_expected=10001&_since=10000": + { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + 'Etag: "10001"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: " ({ + id: `record-${i}`, + ...record, + })), + { + clear: true, + } + ); +} + +add_setup(() => { + client = RemoteSettings("some-key"); +}); + +add_task(async function test_returns_all_without_target() { + await createRecords([ + { + passwordSelector: "#pass-signin", + }, + { + filter_expression: null, + }, + { + filter_expression: "", + }, + ]); + + const list = await client.get(); + equal(list.length, 3); +}); + +add_task(async function test_filters_can_be_disabled() { + const c = RemoteSettings("no-jexl", { filterCreator: null }); + await c.db.importChanges({}, 42, [ + { + id: "abc", + filter_expression: "1 == 2", + }, + ]); + + const list = await c.get(); + equal(list.length, 1); +}); + +add_task(async function test_returns_entries_where_jexl_is_true() { + await createRecords([ + { + willMatch: true, + filter_expression: "1", + }, + { + willMatch: true, + filter_expression: "[42]", + }, + { + willMatch: true, + filter_expression: "1 == 2 || 1 == 1", + }, + { + willMatch: true, + filter_expression: 'env.appinfo.ID == "xpcshell@tests.mozilla.org"', + }, + { + willMatch: false, + filter_expression: "env.version == undefined", + }, + { + willMatch: true, + filter_expression: "env.unknown == undefined", + }, + { + willMatch: false, + filter_expression: "1 == 2", + }, + ]); + + const list = await client.get(); + equal(list.length, 5); + ok(list.every(e => e.willMatch)); +}); + +add_task(async function test_ignores_entries_where_jexl_is_invalid() { + await createRecords([ + { + filter_expression: "true === true", // JavaScript Error: "Invalid expression token: =" + }, + { + filter_expression: "Objects.keys({}) == []", // Token ( (openParen) unexpected in expression + }, + ]); + + const list = await client.get(); + equal(list.length, 0); +}); + +add_task(async function test_support_of_date_filters() { + await createRecords([ + { + willMatch: true, + filter_expression: '"1982-05-08"|date < "2016-03-22"|date', + }, + { + willMatch: false, + filter_expression: '"2000-01-01"|date < "1970-01-01"|date', + }, + ]); + + const list = await client.get(); + equal(list.length, 1); + ok(list.every(e => e.willMatch)); +}); + +add_task(async function test_support_of_preferences_filters() { + await createRecords([ + { + willMatch: true, + filter_expression: '"services.settings.last_etag"|preferenceValue == 42', + }, + { + willMatch: true, + filter_expression: + '"services.settings.poll_interval"|preferenceExists == true', + }, + { + willMatch: true, + filter_expression: + '"services.settings.poll_interval"|preferenceIsUserSet == false', + }, + { + willMatch: true, + filter_expression: + '"services.settings.last_etag"|preferenceIsUserSet == true', + }, + ]); + + // Set a pref for the user. + Services.prefs.setIntPref("services.settings.last_etag", 42); + + const list = await client.get(); + equal(list.length, 4); + ok(list.every(e => e.willMatch)); +}); + +add_task(async function test_support_of_intersect_operator() { + await createRecords([ + { + willMatch: true, + filter_expression: '{foo: 1, bar: 2}|keys intersect ["foo"]', + }, + { + willMatch: true, + filter_expression: '(["a", "b"] intersect ["a", 1, 4]) == "a"', + }, + { + willMatch: false, + filter_expression: '(["a", "b"] intersect [3, 1, 4]) == "c"', + }, + { + willMatch: true, + filter_expression: ` + [1, 2, 3] + intersect + [3, 4, 5] + `, + }, + ]); + + const list = await client.get(); + equal(list.length, 3); + ok(list.every(e => e.willMatch)); +}); + +add_task(async function test_support_of_samples() { + await createRecords([ + { + willMatch: true, + filter_expression: '"always-true"|stableSample(1)', + }, + { + willMatch: false, + filter_expression: '"always-false"|stableSample(0)', + }, + { + willMatch: true, + filter_expression: '"turns-to-true-0"|stableSample(0.5)', + }, + { + willMatch: false, + filter_expression: '"turns-to-false-1"|stableSample(0.5)', + }, + { + willMatch: true, + filter_expression: '"turns-to-true-0"|bucketSample(0, 50, 100)', + }, + { + willMatch: false, + filter_expression: '"turns-to-false-1"|bucketSample(0, 50, 100)', + }, + ]); + + const list = await client.get(); + equal(list.length, 3); + ok(list.every(e => e.willMatch)); +}); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_offline.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_offline.js new file mode 100644 index 000000000..deea90601 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_offline.js @@ -0,0 +1,139 @@ +// A collection with a dump that's packaged on all builds where this test runs, +// including on Android at mobile/android/installer/package-manifest.in +const TEST_BUCKET = "main"; +const TEST_COLLECTION = "password-recipes"; + +let client; +let DUMP_RECORDS; +let DUMP_LAST_MODIFIED; + +add_setup(async () => { + // "services.settings.server" pref is not set. + // Test defaults to an unreachable server, + // and will only load from the dump if any. + + client = new RemoteSettingsClient(TEST_COLLECTION, { + bucketName: TEST_BUCKET, + }); + + const dump = await SharedUtils.loadJSONDump(TEST_BUCKET, TEST_COLLECTION); + DUMP_RECORDS = dump.data; + DUMP_LAST_MODIFIED = dump.timestamp; + + // Dumps are fetched via the following, which sorts the records, newest first. + // https://searchfox.org/mozilla-central/rev/5b3444ad300e244b5af4214212e22bd9e4b7088a/taskcluster/docker/periodic-updates/scripts/periodic_file_updates.sh#304 + equal( + DUMP_LAST_MODIFIED, + DUMP_RECORDS[0].last_modified, + "records in dump ought to be sorted by last_modified" + ); +}); + +async function importData(records) { + await RemoteSettingsWorker._execute("_test_only_import", [ + TEST_BUCKET, + TEST_COLLECTION, + records, + records[0]?.last_modified || 0, + ]); +} + +async function clear_state() { + await client.db.clear(); +} + +add_task(async function test_load_from_dump_when_offline() { + // Baseline: verify that the collection is empty at first, + // but non-empty after loading from the dump. + const beforeTimestamp = await client.db.getLastModified(); + equal(beforeTimestamp, null, "collection empty when offline"); + + // should import from dump since collection was not initialized. + const after = await client.get(); + equal(after.length, DUMP_RECORDS.length, "collection loaded from dump"); + equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp"); +}); +add_task(clear_state); + +add_task(async function test_optional_skip_dump_after_empty_import() { + // clear_state should have wiped the database. + const beforeTimestamp = await client.db.getLastModified(); + equal(beforeTimestamp, null, "collection empty after clearing"); + + // Verify that the dump is not imported again by client.get() + // when the database is initialized with an empty dump + // with `loadDumpIfNewer` disabled. + await importData([]); // <-- Empty set of records. + + const after = await client.get({ loadDumpIfNewer: false }); + equal(after.length, 0, "collection still empty due to import"); + equal(await client.getLastModified(), 0, "Empty dump has no timestamp"); +}); +add_task(clear_state); + +add_task(async function test_optional_skip_dump_after_non_empty_import() { + await importData([{ last_modified: 1234, id: "dummy" }]); + + const after = await client.get({ loadDumpIfNewer: false }); + equal(after.length, 1, "Imported dummy data"); + equal(await client.getLastModified(), 1234, "Expected timestamp of import"); + + await importData([]); + const after2 = await client.get({ loadDumpIfNewer: false }); + equal(after2.length, 0, "Previous data wiped on duplicate import"); + equal(await client.getLastModified(), 0, "Timestamp of empty collection"); +}); +add_task(clear_state); + +add_task(async function test_load_dump_after_empty_import() { + await importData([]); // <-- Empty set of records, i.e. last_modified = 0. + + const after = await client.get(); + equal(after.length, DUMP_RECORDS.length, "Imported dump"); + equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp"); +}); +add_task(clear_state); + +add_task(async function test_load_dump_after_non_empty_import() { + // Dump is updated regularly, verify that the dump matches our expectations + // before running the test. + Assert.greater( + DUMP_LAST_MODIFIED, + 1234, + "Assuming dump to be newer than dummy 1234" + ); + + await importData([{ last_modified: 1234, id: "dummy" }]); + + const after = await client.get(); + equal(after.length, DUMP_RECORDS.length, "Imported dump"); + equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp"); +}); +add_task(clear_state); + +add_task(async function test_load_dump_after_import_from_broken_distro() { + // Dump is updated regularly, verify that the dump matches our expectations + // before running the test. + Assert.greater( + DUMP_LAST_MODIFIED, + 1234, + "Assuming dump to be newer than dummy 1234" + ); + + // No last_modified time. + await importData([{ id: "dummy" }]); + + const after = await client.get(); + equal(after.length, DUMP_RECORDS.length, "Imported dump"); + equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp"); +}); +add_task(clear_state); + +add_task(async function test_skip_dump_if_same_last_modified() { + await importData([{ last_modified: DUMP_LAST_MODIFIED, id: "dummy" }]); + + const after = await client.get(); + equal(after.length, 1, "Not importing dump when time matches"); + equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "Same timestamp"); +}); +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_older_than_local.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_older_than_local.js new file mode 100644 index 000000000..682849669 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_older_than_local.js @@ -0,0 +1,149 @@ +const PREF_SETTINGS_SERVER = "services.settings.server"; + +let server; +let client; + +async function clear_state() { + await client.db.clear(); +} + +function setJSONResponse(response, changeset) { + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.write(JSON.stringify(changeset)); +} + +add_setup(() => { + Services.prefs.setStringPref("services.settings.loglevel", "debug"); + + // Set up an HTTP Server + server = new HttpServer(); + server.start(-1); + + Services.prefs.setStringPref( + PREF_SETTINGS_SERVER, + `http://localhost:${server.identity.primaryPort}/v1` + ); + + client = RemoteSettings("some-cid"); + + const bodies = { + // First successful sync at timestamp=333 + "/v1/buckets/main/collections/some-cid/changeset?_expected=333": { + timestamp: 333, + metadata: { + signatures: [{ signature: "abc", x5u: "data:text/plain;base64,pem" }], + }, + changes: [ + { id: "rid2", last_modified: 22 }, + { id: "rid1", last_modified: 11 }, + ], + }, + // Client fetches with _expected=222&_since=333 + "/v1/buckets/main/collections/some-cid/changeset?_expected=222&_since=333": + { + timestamp: 222, + metadata: { + signatures: [{ signature: "ghi", x5u: "data:text/plain;base64,pem" }], + }, + changes: [{ id: "rid1", last_modified: 11 }], + }, + // Client refetches full collection with _expected=222 after signature fails. + "/v1/buckets/main/collections/some-cid/changeset?_expected=222": { + timestamp: 222, + metadata: { + signatures: [{ signature: "ghi", x5u: "data:text/plain;base64,pem" }], + }, + changes: [{ id: "rid1", last_modified: 11 }], + }, + }; + const handler = (request, response) => { + const body = bodies[`${request.path}?${request.queryString}`]; + if (!body) { + const err = new Error( + `Unexpected request ${request.path}?${request.queryString}` + ); + console.error(err); + throw err; + } + setJSONResponse(response, body); + }; + Object.keys(bodies).map(path => + server.registerPathHandler(path.split("?")[0], handler) + ); + + // In this test suite, the only valid data is the one at timestamp=222 or timestamp=333, but not a mix of the two. + let backup = client._verifier; + client._verifier = { + asyncVerifyContentSignature: serialized => { + return ( + serialized == + '{"data":[{"id":"rid2","last_modified":22}],"last_modified":"444"}' || + serialized == + '{"data":[{"id":"rid1","last_modified":11},{"id":"rid2","last_modified":22}],"last_modified":"333"}' || + serialized == + '{"data":[{"id":"rid1","last_modified":11}],"last_modified":"222"}' + ); + }, + }; + + registerCleanupFunction(() => { + client._verifier = backup; + server.stop(() => {}); + Services.prefs.clearUserPref(PREF_SETTINGS_SERVER); + + Services.prefs.clearUserPref("services.settings.loglevel"); + }); +}); + +add_task(clear_state); + +add_task(async function test_ignores_if_local_signature_is_valid() { + await client.maybeSync(333); + Assert.deepEqual( + [ + await client.db.getLastModified(), + (await client.get()).map(({ id }) => id), + ], + [333, ["rid1", "rid2"]] + ); + + await client.maybeSync(222); + + // The client should detect that timestamp is older, + // but it ignores it because local data is good. + Assert.deepEqual( + [ + await client.db.getLastModified(), + (await client.get()).map(({ id }) => id), + ], + [333, ["rid1", "rid2"]] + ); +}); +add_task(clear_state); + +add_task(async function test_uses_old_data_if_local_signature_is_invalid() { + // Import some data in the local DB. Signature will be bad. + await client.db.importChanges({}, 333, [{ id: "myid", last_modified: 1234 }]); + Assert.deepEqual( + [ + await client.db.getLastModified(), + (await client.get()).map(({ id }) => id), + ], + [333, ["myid"]] + ); + + await client.maybeSync(222); + + // The client should detect that timestamp is older, + // but will replace local data with this old data. + Assert.deepEqual( + [ + await client.db.getLastModified(), + (await client.get()).map(({ id }) => id), + ], + [222, ["rid1"]] + ); +}); +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_poll.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_poll.js new file mode 100644 index 000000000..927342824 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_poll.js @@ -0,0 +1,1287 @@ +const { pushBroadcastService } = ChromeUtils.importESModule( + "resource://gre/modules/PushBroadcastService.sys.mjs" +); + +const { remoteSettingsBroadcastHandler, BROADCAST_ID } = + ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" + ); + +const PREF_SETTINGS_SERVER = "services.settings.server"; +const PREF_SETTINGS_SERVER_BACKOFF = "services.settings.server.backoff"; +const PREF_LAST_UPDATE = "services.settings.last_update_seconds"; +const PREF_LAST_ETAG = "services.settings.last_etag"; +const PREF_CLOCK_SKEW_SECONDS = "services.settings.clock_skew_seconds"; + +// Telemetry report result. +const TELEMETRY_SOURCE_POLL = "settings-changes-monitoring"; +const TELEMETRY_SOURCE_SYNC = "settings-sync"; +const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH; + +var server; + +async function clear_state() { + // set up prefs so the kinto updater talks to the test server + Services.prefs.setStringPref( + PREF_SETTINGS_SERVER, + `http://localhost:${server.identity.primaryPort}/v1` + ); + + // set some initial values so we can check these are updated appropriately + Services.prefs.setIntPref(PREF_LAST_UPDATE, 0); + Services.prefs.setIntPref(PREF_CLOCK_SKEW_SECONDS, 0); + Services.prefs.clearUserPref(PREF_LAST_ETAG); + + // Clear events snapshot. + Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true); + Services.fog.testResetFOG(); + enableUptakeMetric(); + + // Clear sync history. + await new SyncHistory("").clear(); +} + +function serveChangesEntries(serverTime, entriesOrFunc) { + return (request, response) => { + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date(serverTime).toUTCString()); + const entries = + typeof entriesOrFunc == "function" ? entriesOrFunc() : entriesOrFunc; + const latest = entries[0]?.last_modified ?? 42; + if (entries.length) { + response.setHeader("ETag", `"${latest}"`); + } + response.write(JSON.stringify({ timestamp: latest, changes: entries })); + }; +} + +add_setup(() => { + // Set up an HTTP Server + server = new HttpServer(); + server.start(-1); + + registerCleanupFunction(() => { + server.stop(() => {}); + }); +}); + +add_task(clear_state); + +add_task(async function test_an_event_is_sent_on_start() { + server.registerPathHandler(CHANGES_PATH, (request, response) => { + response.write(JSON.stringify({ timestamp: 42, changes: [] })); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("ETag", "42"); + response.setHeader("Date", new Date().toUTCString()); + response.setStatusLine(null, 200, "OK"); + }); + let notificationObserved = null; + const observer = { + observe(aSubject, aTopic, aData) { + Services.obs.removeObserver(this, "remote-settings:changes-poll-start"); + notificationObserved = JSON.parse(aData); + }, + }; + Services.obs.addObserver(observer, "remote-settings:changes-poll-start"); + + await RemoteSettings.pollChanges({ expectedTimestamp: 13 }); + + Assert.equal( + notificationObserved.expectedTimestamp, + 13, + "start notification should have been observed" + ); +}); +add_task(clear_state); + +add_task(async function test_offline_is_reported_if_relevant() { + const offlineBackup = Services.io.offline; + try { + Services.io.offline = true; + + await RemoteSettings.pollChanges(); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR, + }, + ]); + } finally { + Services.io.offline = offlineBackup; + } +}); +add_task(clear_state); + +add_task(async function test_check_success() { + const serverTime = 8000; + + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(serverTime, [ + { + id: "330a0c5f-fadf-ff0b-40c8-4eb0d924ff6a", + last_modified: 1100, + host: "localhost", + bucket: "some-other-bucket", + collection: "test-collection", + }, + { + id: "254cbb9e-6888-4d9f-8e60-58b74faa8778", + last_modified: 1000, + host: "localhost", + bucket: "test-bucket", + collection: "test-collection", + }, + ]) + ); + + // add a test kinto client that will respond to lastModified information + // for a collection called 'test-collection'. + // Let's use a bucket that is not the default one (`test-bucket`). + const c = RemoteSettings("test-collection", { + bucketName: "test-bucket", + }); + let maybeSyncCalled = false; + c.maybeSync = () => { + maybeSyncCalled = true; + }; + + // Ensure that the remote-settings:changes-poll-end notification works + let notificationObserved = false; + const observer = { + observe() { + Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); + notificationObserved = true; + }, + }; + Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); + + await RemoteSettings.pollChanges(); + + // It didn't fail, hence we are sure that the unknown collection ``some-other-bucket/test-collection`` + // was ignored, otherwise it would have tried to reach the network. + + Assert.ok(maybeSyncCalled, "maybeSync was called"); + Assert.ok(notificationObserved, "a notification should have been observed"); + // Last timestamp was saved. An ETag header value is a quoted string. + Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), "1100"); + // check the last_update is updated + Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); + + // ensure that we've accumulated the correct telemetry + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_POLL, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_SYNC, + trigger: "manual", + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_update_timer_interface() { + const remoteSettings = Cc["@mozilla.org/services/settings;1"].getService( + Ci.nsITimerCallback + ); + + const serverTime = 8000; + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(serverTime, [ + { + id: "028261ad-16d4-40c2-a96a-66f72914d125", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "whatever-collection", + }, + ]) + ); + + await new Promise(resolve => { + const e = "remote-settings:changes-poll-end"; + const changesPolledObserver = { + observe() { + Services.obs.removeObserver(this, e); + resolve(); + }, + }; + Services.obs.addObserver(changesPolledObserver, e); + remoteSettings.notify(null); + }); + + // Everything went fine. + Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), "42"); + Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); +}); +add_task(clear_state); + +add_task(async function test_check_up_to_date() { + // Simulate a poll with up-to-date collection. + const serverTime = 4000; + server.registerPathHandler(CHANGES_PATH, serveChangesEntries(serverTime, [])); + + Services.prefs.setStringPref(PREF_LAST_ETAG, "1100"); + + // Ensure that the remote-settings:changes-poll-end notification is sent. + let notificationObserved = false; + const observer = { + observe() { + Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); + notificationObserved = true; + }, + }; + Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); + + // If server has no change, maybeSync() is not called. + let maybeSyncCalled = false; + const c = RemoteSettings("test-collection", { + bucketName: "test-bucket", + }); + c.maybeSync = () => { + maybeSyncCalled = true; + }; + + await RemoteSettings.pollChanges(); + + Assert.ok(notificationObserved, "a notification should have been observed"); + Assert.ok(!maybeSyncCalled, "maybeSync should not be called"); + // Last update is overwritten + Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), serverTime / 1000); + + // ensure that we've accumulated the correct telemetry + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.UP_TO_DATE, + source: TELEMETRY_SOURCE_POLL, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_SYNC, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_expected_timestamp() { + function withCacheBust(request, response) { + const entries = [ + { + id: "695c2407-de79-4408-91c7-70720dd59d78", + last_modified: 1100, + host: "localhost", + bucket: "main", + collection: "with-cache-busting", + }, + ]; + if ( + request.queryString.includes(`_expected=${encodeURIComponent('"42"')}`) + ) { + response.write( + JSON.stringify({ + timestamp: 1110, + changes: entries, + }) + ); + } + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("ETag", '"1100"'); + response.setHeader("Date", new Date().toUTCString()); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, withCacheBust); + + const c = RemoteSettings("with-cache-busting"); + let maybeSyncCalled = false; + c.maybeSync = () => { + maybeSyncCalled = true; + }; + + await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' }); + + Assert.ok(maybeSyncCalled, "maybeSync was called"); +}); +add_task(clear_state); + +add_task(async function test_client_last_check_is_saved() { + server.registerPathHandler(CHANGES_PATH, (request, response) => { + response.write( + JSON.stringify({ + timestamp: 42, + changes: [ + { + id: "695c2407-de79-4408-91c7-70720dd59d78", + last_modified: 1100, + host: "localhost", + bucket: "main", + collection: "models-recipes", + }, + ], + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("ETag", '"42"'); + response.setHeader("Date", new Date().toUTCString()); + response.setStatusLine(null, 200, "OK"); + }); + + const c = RemoteSettings("models-recipes"); + c.maybeSync = () => {}; + + equal( + c.lastCheckTimePref, + "services.settings.main.models-recipes.last_check" + ); + Services.prefs.setIntPref(c.lastCheckTimePref, 0); + + await RemoteSettings.pollChanges({ expectedTimestamp: '"42"' }); + + notEqual(Services.prefs.getIntPref(c.lastCheckTimePref), 0); +}); +add_task(clear_state); + +add_task(async function test_age_of_data_is_reported_in_uptake_status() { + const serverTime = 1552323900000; + const recordsTimestamp = serverTime - 3600 * 1000; + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(serverTime, [ + { + id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", + last_modified: recordsTimestamp, + host: "localhost", + bucket: "main", + collection: "some-entry", + }, + ]) + ); + + await RemoteSettings.pollChanges(); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_POLL, + age: "3600", + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_SYNC, + trigger: "manual", + timestamp: `${recordsTimestamp}`, + duration: d => !!d, + }, + ]); +}); +add_task(clear_state); + +add_task( + async function test_synchronization_duration_is_reported_in_uptake_status() { + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(10000, [ + { + id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "some-entry", + }, + ]) + ); + const c = RemoteSettings("some-entry"); + // Simulate a synchronization that lasts 1 sec. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + c.maybeSync = () => new Promise(resolve => setTimeout(resolve, 1000)); + + await RemoteSettings.pollChanges(); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_POLL, + trigger: "manual", + age: a => !!a, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE_SYNC, + trigger: "manual", + duration: d => parseInt(d) >= 1000, + }, + ]); + } +); +add_task(clear_state); + +add_task(async function test_success_with_partial_list() { + function partialList(request, response) { + const entries = [ + { + id: "028261ad-16d4-40c2-a96a-66f72914d125", + last_modified: 43, + host: "localhost", + bucket: "main", + collection: "cid-1", + }, + { + id: "98a34576-bcd6-423f-abc2-1d290b776ed8", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "poll-test-collection", + }, + ]; + if (request.queryString.includes(`_since=42`)) { + response.write( + JSON.stringify({ + timestamp: 43, + changes: entries.slice(0, 1), + }) + ); + } else { + response.write( + JSON.stringify({ + timestamp: 42, + changes: entries, + }) + ); + } + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, partialList); + + const c = RemoteSettings("poll-test-collection"); + let maybeSyncCount = 0; + c.maybeSync = () => { + maybeSyncCount++; + }; + + await RemoteSettings.pollChanges(); + await RemoteSettings.pollChanges(); + + // On the second call, the server does not mention the poll-test-collection + // and maybeSync() is not called. + Assert.equal(maybeSyncCount, 1, "maybeSync should not be called twice"); +}); +add_task(clear_state); + +add_task(async function test_full_polling() { + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(10000, [ + { + id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "poll-test-collection", + }, + ]) + ); + + const c = RemoteSettings("poll-test-collection"); + let maybeSyncCount = 0; + c.maybeSync = () => { + maybeSyncCount++; + }; + + await RemoteSettings.pollChanges(); + await RemoteSettings.pollChanges({ full: true }); + + // Since the second call is full, clients are called + Assert.equal(maybeSyncCount, 2, "maybeSync should be called twice"); +}); +add_task(clear_state); + +add_task(async function test_server_bad_json() { + function simulateBadJSON(request, response) { + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.write(""); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, simulateBadJSON); + + let error; + try { + await RemoteSettings.pollChanges(); + } catch (e) { + error = e; + } + Assert.ok(/JSON.parse: unexpected character/.test(error.message)); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.PARSE_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_server_bad_content_type() { + function simulateBadContentType(request, response) { + response.setHeader("Content-Type", "text/html"); + response.write(""); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, simulateBadContentType); + + let error; + try { + await RemoteSettings.pollChanges(); + } catch (e) { + error = e; + } + Assert.ok(/Unexpected content-type/.test(error.message)); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.CONTENT_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_server_404_response() { + function simulateDummy404(request, response) { + response.setHeader("Content-Type", "text/html; charset=UTF-8"); + response.write(""); + response.setStatusLine(null, 404, "OK"); + } + server.registerPathHandler(CHANGES_PATH, simulateDummy404); + + await RemoteSettings.pollChanges(); // Does not fail when running from tests. +}); +add_task(clear_state); + +add_task(async function test_server_error() { + // Simulate a server error. + function simulateErrorResponse(request, response) { + response.setHeader("Date", new Date(3000).toUTCString()); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.write( + JSON.stringify({ + code: 503, + errno: 999, + error: "Service Unavailable", + }) + ); + response.setStatusLine(null, 503, "Service Unavailable"); + } + server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); + + let notificationObserved = false; + const observer = { + observe() { + Services.obs.removeObserver(this, "remote-settings:changes-poll-end"); + notificationObserved = true; + }, + }; + Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); + Services.prefs.setIntPref(PREF_LAST_UPDATE, 42); + + // pollChanges() fails with adequate error and no notification. + let error; + try { + await RemoteSettings.pollChanges(); + } catch (e) { + error = e; + } + + Assert.ok( + !notificationObserved, + "a notification should not have been observed" + ); + Assert.ok(/Polling for changes failed/.test(error.message)); + // When an error occurs, last update was not overwritten. + Assert.equal(Services.prefs.getIntPref(PREF_LAST_UPDATE), 42); + // ensure that we've accumulated the correct telemetry + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SERVER_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_server_error_5xx() { + function simulateErrorResponse(request, response) { + response.setHeader("Date", new Date(3000).toUTCString()); + response.setHeader("Content-Type", "text/html; charset=UTF-8"); + response.write(""); + response.setStatusLine(null, 504, "Gateway Timeout"); + } + server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); + + try { + await RemoteSettings.pollChanges(); + } catch (e) {} + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SERVER_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_server_error_4xx() { + function simulateErrorResponse(request, response) { + response.setHeader("Date", new Date(3000).toUTCString()); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + if (request.queryString.includes(`_since=${encodeURIComponent('"abc"')}`)) { + response.setStatusLine(null, 400, "Bad Request"); + response.write(JSON.stringify({})); + } else { + response.setStatusLine(null, 200, "OK"); + response.write(JSON.stringify({ changes: [] })); + } + } + server.registerPathHandler(CHANGES_PATH, simulateErrorResponse); + + Services.prefs.setStringPref(PREF_LAST_ETAG, '"abc"'); + + let error; + try { + await RemoteSettings.pollChanges(); + } catch (e) { + error = e; + } + + Assert.ok(error.message.includes("400 Bad Request"), "Polling failed"); + Assert.ok( + !Services.prefs.prefHasUserValue(PREF_LAST_ETAG), + "Last ETag pref was cleared" + ); + + await RemoteSettings.pollChanges(); // Does not raise. +}); +add_task(clear_state); + +add_task(async function test_client_error() { + const collectionDetails = { + id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "some-entry", + }; + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(10000, [collectionDetails]) + ); + const c = RemoteSettings("some-entry"); + c.maybeSync = () => { + throw new RemoteSettingsClient.CorruptedDataError("main/some-entry"); + }; + + let notificationsObserved = []; + const observer = { + observe(aSubject, aTopic) { + Services.obs.removeObserver(this, aTopic); + notificationsObserved.push([aTopic, aSubject.wrappedJSObject]); + }, + }; + Services.obs.addObserver(observer, "remote-settings:changes-poll-end"); + Services.obs.addObserver(observer, "remote-settings:sync-error"); + Services.prefs.setIntPref(PREF_LAST_ETAG, 42); + + // pollChanges() fails with adequate error and a sync-error notification. + let error; + try { + await RemoteSettings.pollChanges(); + } catch (e) { + error = e; + } + + Assert.equal( + notificationsObserved.length, + 1, + "only the error notification should not have been observed" + ); + console.log(notificationsObserved); + let [topicObserved, subjectObserved] = notificationsObserved[0]; + Assert.equal(topicObserved, "remote-settings:sync-error"); + Assert.ok( + subjectObserved.error instanceof RemoteSettingsClient.CorruptedDataError, + `original error is provided (got ${subjectObserved.error})` + ); + Assert.deepEqual( + subjectObserved.error.details, + collectionDetails, + "information about collection is provided" + ); + + Assert.ok(/Corrupted/.test(error.message), "original client error is thrown"); + // When an error occurs, last etag was not overwritten. + Assert.equal(Services.prefs.getIntPref(PREF_LAST_ETAG), 42); + // ensure that we've accumulated the correct telemetry + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + { + value: UptakeTelemetry.STATUS.SYNC_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_sync_success_is_stored_in_history() { + const collectionDetails = { + last_modified: 444, + bucket: "main", + collection: "desktop-manager", + }; + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(10000, [collectionDetails]) + ); + const c = RemoteSettings("desktop-manager"); + c.maybeSync = () => {}; + try { + await RemoteSettings.pollChanges({ expectedTimestamp: 555 }); + } catch (e) {} + + const { history } = await RemoteSettings.inspect(); + + Assert.deepEqual(history, { + [TELEMETRY_SOURCE_SYNC]: [ + { + timestamp: 444, + status: "success", + infos: {}, + datetime: new Date(444), + }, + ], + }); +}); +add_task(clear_state); + +add_task(async function test_sync_error_is_stored_in_history() { + const collectionDetails = { + last_modified: 1337, + bucket: "main", + collection: "desktop-manager", + }; + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(10000, [collectionDetails]) + ); + const c = RemoteSettings("desktop-manager"); + c.maybeSync = () => { + throw new RemoteSettingsClient.MissingSignatureError( + "main/desktop-manager" + ); + }; + try { + await RemoteSettings.pollChanges({ expectedTimestamp: 123456 }); + } catch (e) {} + + const { history } = await RemoteSettings.inspect(); + + Assert.deepEqual(history, { + [TELEMETRY_SOURCE_SYNC]: [ + { + timestamp: 1337, + status: "sync_error", + infos: { + expectedTimestamp: 123456, + errorName: "MissingSignatureError", + }, + datetime: new Date(1337), + }, + ], + }); +}); +add_task(clear_state); + +add_task( + async function test_sync_broken_signal_is_sent_on_consistent_failure() { + // Wait for the "sync-broken-error" notification. + let notificationObserved = false; + const observer = { + observe() { + notificationObserved = true; + }, + }; + Services.obs.addObserver(observer, "remote-settings:broken-sync-error"); + // Register a client with a failing sync method. + const c = RemoteSettings("desktop-manager"); + c.maybeSync = () => { + throw new RemoteSettingsClient.InvalidSignatureError( + "main/desktop-manager" + ); + }; + // Simulate a response whose ETag gets incremented on each call + // (in order to generate several history entries, indexed by timestamp). + let timestamp = 1337; + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(10000, () => { + return [ + { + last_modified: ++timestamp, + bucket: "main", + collection: "desktop-manager", + }, + ]; + }) + ); + + // Now obtain several failures in a row (less than threshold). + for (var i = 0; i < 9; i++) { + try { + await RemoteSettings.pollChanges(); + } catch (e) {} + } + Assert.ok(!notificationObserved, "Not notified yet"); + + // Clear events snapshot. + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_ALL_CHANNELS, + true + ); + Services.fog.testResetFOG(); + enableUptakeMetric(); + + // Fail again once. Will now notify. + try { + await RemoteSettings.pollChanges(); + } catch (e) {} + Assert.ok(notificationObserved, "Broken sync notified"); + // Uptake event to notify broken sync is sent. + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + { + value: UptakeTelemetry.STATUS.SYNC_ERROR, + }, + { + value: UptakeTelemetry.STATUS.SYNC_BROKEN_ERROR, + }, + ]); + + // Synchronize successfully. + notificationObserved = false; + const failingSync = c.maybeSync; + c.maybeSync = () => {}; + await RemoteSettings.pollChanges(); + + const { history } = await RemoteSettings.inspect(); + Assert.equal( + history[TELEMETRY_SOURCE_SYNC][0].status, + UptakeTelemetry.STATUS.SUCCESS, + "Last sync is success" + ); + Assert.ok(!notificationObserved, "Not notified after success"); + + // Now fail again. Broken sync isn't notified, we need several in a row. + c.maybeSync = failingSync; + try { + await RemoteSettings.pollChanges(); + } catch (e) {} + Assert.ok(!notificationObserved, "Not notified on single error"); + Services.obs.removeObserver(observer, "remote-settings:broken-sync-error"); + } +); +add_task(clear_state); + +add_task(async function test_check_clockskew_is_updated() { + const serverTime = 2000; + + function serverResponse(request, response) { + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date(serverTime).toUTCString()); + response.write(JSON.stringify({ timestamp: 42, changes: [] })); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, serverResponse); + + let startTime = Date.now(); + + await RemoteSettings.pollChanges(); + + // How does the clock difference look? + let endTime = Date.now(); + let clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); + // we previously set the serverTime to 2 (seconds past epoch) + Assert.ok( + clockDifference <= endTime / 1000 && + clockDifference >= Math.floor(startTime / 1000) - serverTime / 1000 + ); + + // check negative clock skew times + // set to a time in the future + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(Date.now() + 10000, []) + ); + + await RemoteSettings.pollChanges(); + + clockDifference = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); + // we previously set the serverTime to Date.now() + 10000 ms past epoch + Assert.ok(clockDifference <= 0 && clockDifference >= -10); +}); +add_task(clear_state); + +add_task(async function test_check_clockskew_takes_age_into_account() { + const currentTime = Date.now(); + const skewSeconds = 5; + const ageCDNSeconds = 3600; + const serverTime = currentTime - skewSeconds * 1000 - ageCDNSeconds * 1000; + + function serverResponse(request, response) { + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date(serverTime).toUTCString()); + response.setHeader("Age", `${ageCDNSeconds}`); + response.write(JSON.stringify({ timestamp: 42, changes: [] })); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, serverResponse); + + await RemoteSettings.pollChanges(); + + const clockSkew = Services.prefs.getIntPref(PREF_CLOCK_SKEW_SECONDS); + Assert.greaterOrEqual(clockSkew, skewSeconds, `clockSkew is ${clockSkew}`); +}); +add_task(clear_state); + +add_task(async function test_backoff() { + function simulateBackoffResponse(request, response) { + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Backoff", "10"); + response.write(JSON.stringify({ timestamp: 42, changes: [] })); + response.setStatusLine(null, 200, "OK"); + } + server.registerPathHandler(CHANGES_PATH, simulateBackoffResponse); + + // First will work. + await RemoteSettings.pollChanges(); + // Second will fail because we haven't waited. + try { + await RemoteSettings.pollChanges(); + // The previous line should have thrown an error. + Assert.ok(false); + } catch (e) { + Assert.ok( + /Server is asking clients to back off; retry in \d+s./.test(e.message) + ); + } + + // Once backoff time has expired, polling for changes can start again. + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(12000, [ + { + id: "6a733d4a-601e-11e8-837a-0f85257529a1", + last_modified: 1300, + host: "localhost", + bucket: "some-bucket", + collection: "some-collection", + }, + ]) + ); + Services.prefs.setStringPref( + PREF_SETTINGS_SERVER_BACKOFF, + `${Date.now() - 1000}` + ); + + await RemoteSettings.pollChanges(); + + // Backoff tracking preference was cleared. + Assert.ok(!Services.prefs.prefHasUserValue(PREF_SETTINGS_SERVER_BACKOFF)); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.UP_TO_DATE, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + { + value: UptakeTelemetry.STATUS.BACKOFF, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_network_error() { + // Simulate a network error (to check telemetry report). + Services.prefs.setStringPref(PREF_SETTINGS_SERVER, "http://localhost:42/v1"); + try { + await RemoteSettings.pollChanges(); + } catch (e) {} + + // ensure that we've accumulated the correct telemetry + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.NETWORK_ERROR, + }, + ]); +}); +add_task(clear_state); + +add_task(async function test_syncs_clients_with_local_database() { + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(42000, [ + { + id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844", + last_modified: 10000, + host: "localhost", + bucket: "main", + collection: "some-unknown", + }, + { + id: "39f57e4e-6023-11e8-8b74-77c8dedfb389", + last_modified: 9000, + host: "localhost", + bucket: "blocklists", + collection: "addons", + }, + { + id: "9a594c1a-601f-11e8-9c8a-33b2239d9113", + last_modified: 8000, + host: "localhost", + bucket: "main", + collection: "recipes", + }, + ]) + ); + + // This simulates what remote-settings would do when initializing a local database. + // We don't want to instantiate a client using the RemoteSettings() API + // since we want to test «unknown» clients that have a local database. + new RemoteSettingsClient("addons", { + bucketName: "blocklists", + }).db.importChanges({}, 42); + new RemoteSettingsClient("recipes").db.importChanges({}, 43); + + let error; + try { + await RemoteSettings.pollChanges(); + Assert.ok(false, "pollChange() should throw when pulling recipes"); + } catch (e) { + error = e; + } + + // The `main/some-unknown` should be skipped because it has no local database. + // The `blocklists/addons` should be skipped because it is not the main bucket. + // The `recipes` has a local database, and should cause a network error because the test + // does not setup the server to receive the requests of `maybeSync()`. + Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync"); + Assert.equal(error.details.collection, "recipes"); +}); +add_task(clear_state); + +add_task(async function test_syncs_clients_with_local_dump() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + server.registerPathHandler( + CHANGES_PATH, + serveChangesEntries(42000, [ + { + id: "d4a14f44-601f-11e8-8b8a-030f3dc5b844", + last_modified: 10000, + host: "localhost", + bucket: "main", + collection: "some-unknown", + }, + { + id: "39f57e4e-6023-11e8-8b74-77c8dedfb389", + last_modified: 9000, + host: "localhost", + bucket: "blocklists", + collection: "addons", + }, + { + id: "9a594c1a-601f-11e8-9c8a-33b2239d9113", + last_modified: 8000, + host: "localhost", + bucket: "main", + collection: "example", + }, + ]) + ); + + let error; + try { + await RemoteSettings.pollChanges(); + } catch (e) { + error = e; + } + + // The `main/some-unknown` should be skipped because it has no dump. + // The `blocklists/addons` should be skipped because it is not the main bucket. + // The `example` has a dump, and should cause a network error because the test + // does not setup the server to receive the requests of `maybeSync()`. + Assert.ok(/HTTP 404/.test(error.message), "server will return 404 on sync"); + Assert.equal(error.details.collection, "example"); +}); +add_task(clear_state); + +add_task(async function test_adding_client_resets_polling() { + function serve200(request, response) { + const entries = [ + { + id: "aa71e6cc-9f37-447a-b6e0-c025e8eabd03", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "a-collection", + }, + ]; + if (request.queryString.includes("_since")) { + response.write( + JSON.stringify({ + timestamp: 42, + changes: [], + }) + ); + } else { + response.write( + JSON.stringify({ + timestamp: 42, + changes: entries, + }) + ); + } + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + } + server.registerPathHandler(CHANGES_PATH, serve200); + + // Poll once, without any client for "a-collection" + await RemoteSettings.pollChanges(); + + // Register a new client. + let maybeSyncCalled = false; + const c = RemoteSettings("a-collection"); + c.maybeSync = () => { + maybeSyncCalled = true; + }; + + // Poll again. + await RemoteSettings.pollChanges(); + + // The new client was called, even if the server data didn't change. + Assert.ok(maybeSyncCalled); + + // Poll again. This time maybeSync() won't be called. + maybeSyncCalled = false; + await RemoteSettings.pollChanges(); + Assert.ok(!maybeSyncCalled); +}); +add_task(clear_state); + +add_task( + async function test_broadcast_handler_passes_version_and_trigger_values() { + // The polling will use the broadcast version as cache busting query param. + let passedQueryString; + function serveCacheBusted(request, response) { + passedQueryString = request.queryString; + const entries = [ + { + id: "b6ba7fab-a40a-4d03-a4af-6b627f3c5b36", + last_modified: 42, + host: "localhost", + bucket: "main", + collection: "from-broadcast", + }, + ]; + response.write( + JSON.stringify({ + changes: entries, + timestamp: 42, + }) + ); + response.setHeader("ETag", "42"); + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + } + server.registerPathHandler(CHANGES_PATH, serveCacheBusted); + + let passedTrigger; + const c = RemoteSettings("from-broadcast"); + c.maybeSync = (last_modified, { trigger }) => { + passedTrigger = trigger; + }; + + const version = "1337"; + + let context = { phase: pushBroadcastService.PHASES.HELLO }; + await remoteSettingsBroadcastHandler.receivedBroadcastMessage( + version, + BROADCAST_ID, + context + ); + Assert.equal(passedTrigger, "startup"); + Assert.equal(passedQueryString, `_expected=${version}`); + + clear_state(); + + context = { phase: pushBroadcastService.PHASES.REGISTER }; + await remoteSettingsBroadcastHandler.receivedBroadcastMessage( + version, + BROADCAST_ID, + context + ); + Assert.equal(passedTrigger, "startup"); + + clear_state(); + + context = { phase: pushBroadcastService.PHASES.BROADCAST }; + await remoteSettingsBroadcastHandler.receivedBroadcastMessage( + version, + BROADCAST_ID, + context + ); + Assert.equal(passedTrigger, "broadcast"); + } +); +add_task(clear_state); + +add_task(async function test_stale_startup_timestamp() { + Services.prefs.setStringPref(PREF_LAST_ETAG, 42); + + let error; + try { + await RemoteSettings.pollChanges({ + trigger: "startup", + expectedTimestamp: "41", + }); + } catch (e) { + error = e; + } + + Assert.equal(error, undefined); + Assert.equal(Services.prefs.getStringPref(PREF_LAST_ETAG), "42"); + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.STALE_EXPECTED, + }, + ]); +}); +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_recover_broken.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_recover_broken.js new file mode 100644 index 000000000..5bb047c8a --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_recover_broken.js @@ -0,0 +1,138 @@ +const PREF_SETTINGS_SERVER = "services.settings.server"; +const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH; +const BROKEN_SYNC_THRESHOLD = 10; // See default pref value + +let server; +let client; +let maybeSyncBackup; + +async function clear_state() { + // Disable logging output. + Services.prefs.setStringPref("services.settings.loglevel", "critical"); + // Pull data from the test server. + Services.prefs.setStringPref( + PREF_SETTINGS_SERVER, + `http://localhost:${server.identity.primaryPort}/v1` + ); + + // Clear sync history. + await new SyncHistory("").clear(); + + // Simulate a response whose ETag gets incremented on each call + // (in order to generate several history entries, indexed by timestamp). + let timestamp = 1337; + server.registerPathHandler(CHANGES_PATH, (request, response) => { + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date(1000000).toUTCString()); + response.setHeader("ETag", `"${timestamp}"`); + response.write( + JSON.stringify({ + timestamp, + changes: [ + { + last_modified: ++timestamp, + bucket: "main", + collection: "desktop-manager", + }, + ], + }) + ); + }); + + // Restore original maybeSync() method between each test. + client.maybeSync = maybeSyncBackup; +} + +function run_test() { + // Set up an HTTP Server + server = new HttpServer(); + server.start(-1); + + client = RemoteSettings("desktop-manager"); + maybeSyncBackup = client.maybeSync; + + run_next_test(); + + registerCleanupFunction(() => { + server.stop(() => {}); + // Restore original maybeSync() method when test suite is done. + client.maybeSync = maybeSyncBackup; + }); +} + +add_task(clear_state); + +add_task(async function test_db_is_destroyed_when_sync_is_broken() { + // Simulate a successful sync. + client.maybeSync = async () => { + // Store some data in local DB. + await client.db.importChanges({}, 1515, []); + }; + await RemoteSettings.pollChanges({ trigger: "timer" }); + + // Register a client with a failing sync method. + client.maybeSync = () => { + throw new RemoteSettingsClient.InvalidSignatureError( + "main/desktop-manager" + ); + }; + + // Now obtain several failures in a row. + for (var i = 0; i < BROKEN_SYNC_THRESHOLD; i++) { + try { + await RemoteSettings.pollChanges({ trigger: "timer" }); + } catch (e) {} + } + + // Synchronization is in broken state. + Assert.equal( + await client.db.getLastModified(), + 1515, + "Local DB was not destroyed yet" + ); + + // Synchronize again. Broken state will be detected. + try { + await RemoteSettings.pollChanges({ trigger: "timer" }); + } catch (e) {} + + // DB was destroyed. + Assert.equal( + await client.db.getLastModified(), + null, + "Local DB was destroyed" + ); +}); + +add_task(clear_state); + +add_task(async function test_db_is_not_destroyed_when_state_is_server_error() { + // Since we don't mock the server endpoints to obtain the changeset of this + // collection, the call to `maybeSync()` will fail with network errors. + + // Store some data in local DB. + await client.db.importChanges({}, 1515, []); + + // Now obtain several failures in a row. + let lastError; + for (var i = 0; i < BROKEN_SYNC_THRESHOLD + 1; i++) { + try { + await RemoteSettings.pollChanges({ trigger: "timer" }); + } catch (e) { + lastError = e; + } + } + Assert.ok( + /Cannot parse server content/.test(lastError.message), + "Error is about server" + ); + // DB was not destroyed. + Assert.equal( + await client.db.getLastModified(), + 1515, + "Local DB was not destroyed" + ); +}); + +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_release_prefs.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_release_prefs.js new file mode 100644 index 000000000..3e1ae7c25 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_release_prefs.js @@ -0,0 +1,252 @@ +"use strict"; + +var nextUniqId = 0; +function getNewUtils() { + const { Utils } = ChromeUtils.importESModule( + `resource://services-settings/Utils.sys.mjs?_${++nextUniqId}` + ); + return Utils; +} + +// A collection with a dump that's packaged on all builds where this test runs, +// including on Android at mobile/android/installer/package-manifest.in +const TEST_BUCKET = "main"; +const TEST_COLLECTION = "password-recipes"; + +async function importData(records) { + await RemoteSettingsWorker._execute("_test_only_import", [ + TEST_BUCKET, + TEST_COLLECTION, + records, + records[0]?.last_modified || 0, + ]); +} + +async function clear_state() { + Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "0"); + Services.prefs.clearUserPref("services.settings.server"); + Services.prefs.clearUserPref("services.settings.preview_enabled"); +} + +add_setup(async function () { + // Set this env vars in order to test the code path where the + // server URL can only be overridden from Dev Tools. + // See `isRunningTests` in `services/settings/Utils.sys.mjs`. + const before = Services.env.get("MOZ_DISABLE_NONLOCAL_CONNECTIONS"); + Services.env.set("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "0"); + + registerCleanupFunction(() => { + clear_state(); + Services.env.set("MOZ_DISABLE_NONLOCAL_CONNECTIONS", before); + }); +}); + +add_task(clear_state); + +add_task( + { + skip_if: () => !AppConstants.RELEASE_OR_BETA, + }, + async function test_server_url_cannot_be_toggled_in_release() { + Services.prefs.setStringPref( + "services.settings.server", + "http://localhost:8888/v1" + ); + + const Utils = getNewUtils(); + + Assert.equal( + Utils.SERVER_URL, + AppConstants.REMOTE_SETTINGS_SERVER_URLS[0], + "Server url pref was not read in release" + ); + } +); + +add_task( + { + skip_if: () => AppConstants.RELEASE_OR_BETA, + }, + async function test_server_url_cannot_be_toggled_in_dev_nightly() { + Services.prefs.setStringPref( + "services.settings.server", + "http://localhost:8888/v1" + ); + + const Utils = getNewUtils(); + + Assert.notEqual( + Utils.SERVER_URL, + AppConstants.REMOTE_SETTINGS_SERVER_URLS[0], + "Server url pref was read in nightly/dev" + ); + } +); +add_task(clear_state); + +add_task( + { + skip_if: () => !AppConstants.RELEASE_OR_BETA, + }, + async function test_preview_mode_cannot_be_toggled_in_release() { + Services.prefs.setBoolPref("services.settings.preview_enabled", true); + + const Utils = getNewUtils(); + + Assert.ok(!Utils.PREVIEW_MODE, "Preview mode pref was not read in release"); + } +); +add_task(clear_state); + +add_task( + { + skip_if: () => AppConstants.RELEASE_OR_BETA, + }, + async function test_preview_mode_cannot_be_toggled_in_dev_nightly() { + Services.prefs.setBoolPref("services.settings.preview_enabled", true); + + const Utils = getNewUtils(); + + Assert.ok(Utils.PREVIEW_MODE, "Preview mode pref is read in dev/nightly"); + } +); +add_task(clear_state); + +add_task( + { + skip_if: () => !AppConstants.RELEASE_OR_BETA, + }, + async function test_load_dumps_will_always_be_loaded_in_release() { + Services.prefs.setStringPref( + "services.settings.server", + "http://localhost:8888/v1" + ); + + const Utils = getNewUtils(); + + Assert.equal( + Utils.SERVER_URL, + AppConstants.REMOTE_SETTINGS_SERVER_URLS[0], + "Server url pref was not read" + ); + Assert.ok(Utils.LOAD_DUMPS, "Dumps will always be loaded"); + } +); + +add_task( + { + skip_if: () => AppConstants.RELEASE_OR_BETA, + }, + async function test_load_dumps_can_be_disabled_in_dev_nightly() { + Services.prefs.setStringPref( + "services.settings.server", + "http://localhost:8888/v1" + ); + + const Utils = getNewUtils(); + + Assert.notEqual( + Utils.SERVER_URL, + AppConstants.REMOTE_SETTINGS_SERVER_URLS[0], + "Server url pref was read" + ); + Assert.ok(!Utils.LOAD_DUMPS, "Dumps are not loaded if server is not prod"); + } +); +add_task(clear_state); + +add_task( + async function test_server_url_can_be_changed_in_all_versions_if_running_for_devtools() { + Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1"); + Services.prefs.setStringPref( + "services.settings.server", + "http://localhost:8888/v1" + ); + + const Utils = getNewUtils(); + + Assert.notEqual( + Utils.SERVER_URL, + AppConstants.REMOTE_SETTINGS_SERVER_URLS[0], + "Server url pref was read" + ); + } +); +add_task(clear_state); + +add_task( + async function test_preview_mode_can_be_changed_in_all_versions_if_running_for_devtools() { + Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1"); + Services.prefs.setBoolPref("services.settings.preview_enabled", true); + + const Utils = getNewUtils(); + + Assert.ok(Utils.PREVIEW_MODE, "Preview mode pref was read"); + } +); +add_task(clear_state); + +add_task( + async function test_dumps_are_not_loaded_if_server_is_not_prod_if_running_for_devtools() { + Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1"); + Services.prefs.setStringPref( + "services.settings.server", + "http://localhost:8888/v1" + ); + + const Utils = getNewUtils(); + Assert.ok(!Utils.LOAD_DUMPS, "Dumps won't be loaded"); + + // The section below ensures that the LOAD_DUMPS flag properly takes effect. + // The client is set up here rather than add_setup to avoid triggering the + // lazy getters that are behind the global Utils.LOAD_DUMPS. If they are + // triggered too early, then they will potentially cache different values + // for the server urls and environment variables and this test then won't be + // testing what we expect it to. + + let client = new RemoteSettingsClient(TEST_COLLECTION); + + const dump = await SharedUtils.loadJSONDump(TEST_BUCKET, TEST_COLLECTION); + let DUMP_LAST_MODIFIED = dump.timestamp; + + // Dump is updated regularly, verify that the dump matches our expectations + // before running the test. + Assert.greater( + DUMP_LAST_MODIFIED, + 1234, + "Assuming dump to be newer than dummy 1234" + ); + + await client.db.clear(); + await importData([{ last_modified: 1234, id: "dummy" }]); + + const after = await client.get(); + Assert.deepEqual( + after, + [{ last_modified: 1234, id: "dummy" }], + "Should have kept the original import" + ); + Assert.equal( + await client.getLastModified(), + 1234, + "Should have kept the import's timestamp" + ); + await client.db.clear(); + } +); +add_task(clear_state); + +add_task( + async function test_dumps_are_loaded_if_server_is_prod_if_running_for_devtools() { + Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1"); + Services.prefs.setStringPref( + "services.settings.server", + AppConstants.REMOTE_SETTINGS_SERVER_URLS[0] + ); + + const Utils = getNewUtils(); + + Assert.ok(Utils.LOAD_DUMPS, "dumps are loaded if prod"); + } +); +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures.js new file mode 100644 index 000000000..8b50c3519 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures.js @@ -0,0 +1,952 @@ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ +"use strict"; + +const PREF_SETTINGS_SERVER = "services.settings.server"; +const SIGNER_NAME = "onecrl.content-signature.mozilla.org"; + +const CERT_DIR = "test_remote_settings_signatures/"; +const CHAIN_FILES = ["collection_signing_ee.pem", "collection_signing_int.pem"]; + +function getFileData(file) { + const stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + stream.init(file, -1, 0, 0); + const data = NetUtil.readInputStreamToString(stream, stream.available()); + stream.close(); + return data; +} + +function getCertChain() { + const chain = []; + for (let file of CHAIN_FILES) { + chain.push(getFileData(do_get_file(CERT_DIR + file))); + } + return chain.join("\n"); +} + +let server; +let client; + +add_setup(() => { + // Signature verification is enabled by default. We use a custom signer + // because these tests were originally written for OneCRL. + client = RemoteSettings("signed", { signerName: SIGNER_NAME }); + + Services.prefs.setStringPref("services.settings.loglevel", "debug"); + + // Set up an HTTP Server + server = new HttpServer(); + server.start(-1); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("services.settings.loglevel"); + Services.prefs.clearUserPref(PREF_SETTINGS_SERVER); + server.stop(() => {}); + }); +}); + +add_task(async function test_check_signatures() { + // First, perform a signature verification with known data and signature + // to ensure things are working correctly + let verifier = Cc[ + "@mozilla.org/security/contentsignatureverifier;1" + ].createInstance(Ci.nsIContentSignatureVerifier); + + const emptyData = "[]"; + const emptySignature = + "p384ecdsa=zbugm2FDitsHwk5-IWsas1PpWwY29f0Fg5ZHeqD8fzep7AVl2vfcaHA7LdmCZ28qZLOioGKvco3qT117Q4-HlqFTJM7COHzxGyU2MMJ0ZTnhJrPOC1fP3cVQjU1PTWi9"; + + ok( + await verifier.asyncVerifyContentSignature( + emptyData, + emptySignature, + getCertChain(), + SIGNER_NAME, + Ci.nsIX509CertDB.AppXPCShellRoot + ) + ); + + const collectionData = + '[{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:43:37Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"97fbf7c4-3ef2-f54f-0029-1ba6540c63ea","issuerName":"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==","last_modified":2000,"serialNumber":"BAAAAAABA/A35EU="},{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:48:11Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc","issuerName":"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB","last_modified":3000,"serialNumber":"BAAAAAABI54PryQ="}]'; + const collectionSignature = + "p384ecdsa=f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p"; + + ok( + await verifier.asyncVerifyContentSignature( + collectionData, + collectionSignature, + getCertChain(), + SIGNER_NAME, + Ci.nsIX509CertDB.AppXPCShellRoot + ) + ); +}); + +add_task(async function test_bad_signature_does_not_lead_to_empty_list() { + Services.prefs.setStringPref( + PREF_SETTINGS_SERVER, + `http://localhost:${server.identity.primaryPort}/v1` + ); + const x5u = `http://localhost:${server.identity.primaryPort}/x5u.pem`; + + const networkCalls = []; + + server.registerPathHandler( + "/v1/buckets/monitor/collections/changes/changeset", + (request, response) => { + response.write( + JSON.stringify({ + changes: [ + { + bucket: "main", + collection: "no-dump-no-local-data", + last_modified: 42, + }, + ], + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + } + ); + server.registerPathHandler( + "/v1/buckets/monitor/collections/changes/changeset", + (request, response) => { + networkCalls.push(request); + response.write( + JSON.stringify({ + changes: [ + { + bucket: "main", + collection: "no-dump-no-local-data", + last_modified: 42, + }, + ], + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + } + ); + server.registerPathHandler( + "/v1/buckets/main/collections/no-dump-no-local-data/changeset", + (request, response) => { + response.write( + JSON.stringify({ + timestamp: 42, + changes: [], + metadata: { + signatures: [ + { + signature: "bad-signature", + x5u, + }, + ], + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + } + ); + server.registerPathHandler("/x5u.pem", (request, response) => { + response.write(getCertChain()); // At least cert will be valid. + response.setHeader("Content-Type", "text/plain; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + + const clientEmpty = RemoteSettings("no-dump-no-local-data"); + clientEmpty.verifySignature = true; // default + + // Check that client.get() will initiate a sync, + // and that it will throw since the signature is bad, + // and not return an empty list (`emptyListFallback: false`) + let error; + try { + await clientEmpty.get({ + emptyListFallback: false, + syncIfEmpty: true, // default value + }); + } catch (exc) { + error = exc; + } + equal(error.name, "InvalidSignatureError"); + + // Even running client.sync() will throw and won't leave + // anything in the database. + error = null; + try { + await clientEmpty.sync(); + } catch (exc) { + error = exc; + } + equal(error.name, "InvalidSignatureError"); + equal(await clientEmpty.db.getLastModified(), null); + + // Call .get() again will initiate another sync. + networkCalls.length = 0; + try { + await clientEmpty.get({ + emptyListFallback: false, + syncIfEmpty: true, // default value + }); + } catch (exc) { + error = exc; + } + Assert.greater(networkCalls.length, 0, "Network calls were made"); + equal(error.name, "InvalidSignatureError"); +}); + +add_task(async function test_check_synchronization_with_signatures() { + const port = server.identity.primaryPort; + + const x5u = `http://localhost:${port}/test_remote_settings_signatures/test_cert_chain.pem`; + + // Telemetry reports. + const TELEMETRY_SOURCE = client.identifier; + + function registerHandlers(responses) { + function handleResponse(serverTimeMillis, request, response) { + const key = `${request.method}:${request.path}?${request.queryString}`; + const available = responses[key]; + const sampled = available.length > 1 ? available.shift() : available[0]; + if (!sampled) { + do_throw( + `unexpected ${request.method} request for ${request.path}?${request.queryString}` + ); + } + + response.setStatusLine( + null, + sampled.status.status, + sampled.status.statusText + ); + // send the headers + for (let headerLine of sampled.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + + // set the server date + response.setHeader("Date", new Date(serverTimeMillis).toUTCString()); + + response.write(sampled.responseBody); + } + + for (let key of Object.keys(responses)) { + const keyParts = key.split(":"); + const valueParts = keyParts[1].split("?"); + const path = valueParts[0]; + + server.registerPathHandler(path, handleResponse.bind(null, 2000)); + } + } + + // set up prefs so the kinto updater talks to the test server + Services.prefs.setStringPref( + PREF_SETTINGS_SERVER, + `http://localhost:${server.identity.primaryPort}/v1` + ); + + // These are records we'll use in the test collections + const RECORD1 = { + details: { + bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145", + created: "2016-01-18T14:43:37Z", + name: "GlobalSign certs", + who: ".", + why: ".", + }, + enabled: true, + id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea", + issuerName: + "MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==", + last_modified: 2000, + serialNumber: "BAAAAAABA/A35EU=", + }; + + const RECORD2 = { + details: { + bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145", + created: "2016-01-18T14:48:11Z", + name: "GlobalSign certs", + who: ".", + why: ".", + }, + enabled: true, + id: "e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc", + issuerName: + "MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB", + last_modified: 3000, + serialNumber: "BAAAAAABI54PryQ=", + }; + + const RECORD3 = { + details: { + bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145", + created: "2016-01-18T14:48:11Z", + name: "GlobalSign certs", + who: ".", + why: ".", + }, + enabled: true, + id: "c7c49b69-a4ab-418e-92a9-e1961459aa7f", + issuerName: + "MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB", + last_modified: 4000, + serialNumber: "BAAAAAABI54PryQ=", + }; + + const RECORD1_DELETION = { + deleted: true, + enabled: true, + id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea", + last_modified: 3500, + }; + + // Check that a signature on an empty collection is OK + // We need to set up paths on the HTTP server to return specific data from + // specific paths for each test. Here we prepare data for each response. + + // A cert chain response (this the cert chain that contains the signing + // cert, the root and any intermediates in between). This is used in each + // sync. + const RESPONSE_CERT_CHAIN = { + comment: "RESPONSE_CERT_CHAIN", + sampleHeaders: ["Content-Type: text/plain; charset=UTF-8"], + status: { status: 200, statusText: "OK" }, + responseBody: getCertChain(), + }; + + // A server settings response. This is used in each sync. + const RESPONSE_SERVER_SETTINGS = { + comment: "RESPONSE_SERVER_SETTINGS", + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + settings: { + batch_max_requests: 25, + }, + url: `http://localhost:${port}/v1/`, + documentation: "https://kinto.readthedocs.org/", + version: "1.5.1", + commit: "cbc6f58", + hello: "kinto", + }), + }; + + // This is the initial, empty state of the collection. This is only used + // for the first sync. + const RESPONSE_EMPTY_INITIAL = { + comment: "RESPONSE_EMPTY_INITIAL", + sampleHeaders: [ + "Content-Type: application/json; charset=UTF-8", + 'ETag: "1000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + timestamp: 1000, + metadata: { + signatures: [ + { + x5u, + signature: + "vxuAg5rDCB-1pul4a91vqSBQRXJG_j7WOYUTswxRSMltdYmbhLRH8R8brQ9YKuNDF56F-w6pn4HWxb076qgKPwgcEBtUeZAO_RtaHXRkRUUgVzAr86yQL4-aJTbv3D6u", + }, + ], + }, + changes: [], + }), + }; + + // Here, we map request method and path to the available responses + const emptyCollectionResponses = { + "GET:/test_remote_settings_signatures/test_cert_chain.pem?": [ + RESPONSE_CERT_CHAIN, + ], + "GET:/v1/?": [RESPONSE_SERVER_SETTINGS], + "GET:/v1/buckets/main/collections/signed/changeset?_expected=1000": [ + RESPONSE_EMPTY_INITIAL, + ], + }; + + // + // 1. + // - collection: undefined -> [] + // - timestamp: undefined -> 1000 + // + + // .. and use this map to register handlers for each path + registerHandlers(emptyCollectionResponses); + + // Clear events snapshot. + Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true); + Services.fog.testResetFOG(); + enableUptakeMetric(); + + // With all of this set up, we attempt a sync. This will resolve if all is + // well and throw if something goes wrong. + await client.maybeSync(1000); + + equal((await client.get()).length, 0); + + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + source: TELEMETRY_SOURCE, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.SUCCESS, + source: TELEMETRY_SOURCE, + trigger: "manual", + }, + ]); + + // + // 2. + // - collection: [] -> [RECORD2, RECORD1] + // - timestamp: 1000 -> 3000 + // + // Check that some additions (2 records) to the collection have a valid + // signature. + + // This response adds two entries (RECORD1 and RECORD2) to the collection + const RESPONSE_TWO_ADDED = { + comment: "RESPONSE_TWO_ADDED", + sampleHeaders: [ + "Content-Type: application/json; charset=UTF-8", + 'ETag: "3000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + timestamp: 3000, + metadata: { + signatures: [ + { + x5u, + signature: + "dwhJeypadNIyzGj3QdI0KMRTPnHhFPF_j73mNrsPAHKMW46S2Ftf4BzsPMvPMB8h0TjDus13wo_R4l432DHe7tYyMIWXY0PBeMcoe5BREhFIxMxTsh9eGVXBD1e3UwRy", + }, + ], + }, + changes: [RECORD2, RECORD1], + }), + }; + + const twoItemsResponses = { + "GET:/v1/buckets/main/collections/signed/changeset?_expected=3000&_since=1000": + [RESPONSE_TWO_ADDED], + }; + registerHandlers(twoItemsResponses); + await client.maybeSync(3000); + + equal((await client.get()).length, 2); + + // + // 3. + // - collection: [RECORD2, RECORD1] -> [RECORD2, RECORD3] + // - timestamp: 3000 -> 4000 + // + // Check the collection with one addition and one removal has a valid + // signature + const THREE_ITEMS_SIG = + "MIEmNghKnkz12UodAAIc3q_Y4a3IJJ7GhHF4JYNYmm8avAGyPM9fYU7NzVo94pzjotG7vmtiYuHyIX2rTHTbT587w0LdRWxipgFd_PC1mHiwUyjFYNqBBG-kifYk7kEw"; + + // Remove RECORD1, add RECORD3 + const RESPONSE_ONE_ADDED_ONE_REMOVED = { + comment: "RESPONSE_ONE_ADDED_ONE_REMOVED ", + sampleHeaders: [ + "Content-Type: application/json; charset=UTF-8", + 'ETag: "4000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + timestamp: 4000, + metadata: { + signatures: [ + { + x5u, + signature: THREE_ITEMS_SIG, + }, + ], + }, + changes: [RECORD3, RECORD1_DELETION], + }), + }; + + const oneAddedOneRemovedResponses = { + "GET:/v1/buckets/main/collections/signed/changeset?_expected=4000&_since=3000": + [RESPONSE_ONE_ADDED_ONE_REMOVED], + }; + registerHandlers(oneAddedOneRemovedResponses); + await client.maybeSync(4000); + + equal((await client.get()).length, 2); + + // + // 4. + // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] + // - timestamp: 4000 -> 4100 + // + // Check the signature is still valid with no operation (no changes) + + // Leave the collection unchanged + const RESPONSE_EMPTY_NO_UPDATE = { + comment: "RESPONSE_EMPTY_NO_UPDATE ", + sampleHeaders: [ + "Content-Type: application/json; charset=UTF-8", + 'ETag: "4000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + timestamp: 4000, + metadata: { + signatures: [ + { + x5u, + signature: THREE_ITEMS_SIG, + }, + ], + }, + changes: [], + }), + }; + + const noOpResponses = { + "GET:/v1/buckets/main/collections/signed/changeset?_expected=4100&_since=4000": + [RESPONSE_EMPTY_NO_UPDATE], + }; + registerHandlers(noOpResponses); + await client.maybeSync(4100); + + equal((await client.get()).length, 2); + + // + // 5. + // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] + // - timestamp: 4000 -> 5000 + // + // Check the collection is reset when the signature is invalid. + // Client will: + // - Fetch metadata (with bad signature) + // - Perform the sync (fetch empty changes) + // - Refetch the metadata and the whole collection + // - Validate signature successfully, but with no changes to emit. + + const RESPONSE_COMPLETE_INITIAL = { + comment: "RESPONSE_COMPLETE_INITIAL ", + sampleHeaders: [ + "Content-Type: application/json; charset=UTF-8", + 'ETag: "4000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + timestamp: 4000, + metadata: { + signatures: [ + { + x5u, + signature: THREE_ITEMS_SIG, + }, + ], + }, + changes: [RECORD2, RECORD3], + }), + }; + + const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG = { + ...RESPONSE_EMPTY_NO_UPDATE, + responseBody: JSON.stringify({ + timestamp: 4000, + metadata: { + signatures: [ + { + x5u, + signature: "aW52YWxpZCBzaWduYXR1cmUK", + }, + ], + }, + changes: [], + }), + }; + + const badSigGoodSigResponses = { + // The first collection state is the three item collection (since + // there was sync with no updates before) - but, since the signature is wrong, + // another request will be made... + "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=4000": + [RESPONSE_EMPTY_NO_UPDATE_BAD_SIG], + // Subsequent signature returned is a valid one for the three item + // collection. + "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [ + RESPONSE_COMPLETE_INITIAL, + ], + }; + + registerHandlers(badSigGoodSigResponses); + Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true); + Services.fog.testResetFOG(); + enableUptakeMetric(); + + let syncEventSent = false; + client.on("sync", () => { + syncEventSent = true; + }); + + await client.maybeSync(5000); + + equal((await client.get()).length, 2); + + // since we only fixed the signature, and no data was changed, the sync event + // was not sent. + equal(syncEventSent, false); + + // ensure that the failure count is incremented for a succesful sync with an + // (initial) bad signature - only SERVICES_SETTINGS_SYNC_SIG_FAIL should + // increment. + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + source: TELEMETRY_SOURCE, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.SIGNATURE_ERROR, + source: TELEMETRY_SOURCE, + trigger: "manual", + }, + ]); + + // + // 6. + // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] + // - timestamp: 4000 -> 5000 + // + // Check the collection is reset when the signature is invalid. + // Client will: + // - Fetch metadata (with bad signature) + // - Perform the sync (fetch empty changes) + // - Refetch the whole collection and metadata + // - Sync will be no-op since local is equal to server, no changes to emit. + + const badSigGoodOldResponses = { + // The first collection state is the current state (since there's no update + // - but, since the signature is wrong, another request will be made) + "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=4000": + [RESPONSE_EMPTY_NO_UPDATE_BAD_SIG], + // The next request is for the full collection. This will be + // checked against the valid signature and last_modified times will be + // compared. Sync should be a no-op, even though the signature is good, + // because the local collection is newer. + "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [ + RESPONSE_EMPTY_INITIAL, + ], + }; + + // ensure our collection hasn't been replaced with an older, empty one + equal((await client.get()).length, 2, "collection was restored"); + + registerHandlers(badSigGoodOldResponses); + + syncEventSent = false; + client.on("sync", () => { + syncEventSent = true; + }); + + await client.maybeSync(5000); + + // Local data was unchanged, since it was never than the one returned by the server, + // thus the sync event is not sent. + equal(syncEventSent, false, "event was not sent"); + + // + // 7. + // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] + // - timestamp: 4000 -> 5000 + // + // Check that a tampered local DB will be overwritten and + // sync event contain the appropriate data. + + const RESPONSE_COMPLETE_BAD_SIG = { + ...RESPONSE_EMPTY_NO_UPDATE, + responseBody: JSON.stringify({ + timestamp: 5000, + metadata: { + signatures: [ + { + x5u, + signature: "aW52YWxpZCBzaWduYXR1cmUK", + }, + ], + }, + changes: [RECORD2, RECORD3], + }), + }; + + const badLocalContentGoodSigResponses = { + "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=3900": + [RESPONSE_COMPLETE_BAD_SIG], + "GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [ + RESPONSE_COMPLETE_INITIAL, + ], + }; + + registerHandlers(badLocalContentGoodSigResponses); + + // we create a local state manually here, in order to test that the sync event data + // properly contains created, updated, and deleted records. + // the local DB contains same id as RECORD2 and a fake record. + // the final server collection contains RECORD2 and RECORD3 + const localId = "0602b1b2-12ab-4d3a-b6fb-593244e7b035"; + await client.db.importChanges( + { signatures: [{ x5u, signature: "abc" }] }, + 3900, + [ + { ...RECORD2, last_modified: 1234567890, serialNumber: "abc" }, + { id: localId }, + ], + { + clear: true, + } + ); + + let syncData = null; + client.on("sync", ({ data }) => { + syncData = data; + }); + + // Clear events snapshot. + Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true); + Services.fog.testResetFOG(); + enableUptakeMetric(); + + // Events telemetry is sampled on released, use fake channel. + await client.maybeSync(5000); + + // We should report a corruption_error. + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + source: client.identifier, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.CORRUPTION_ERROR, + source: client.identifier, + trigger: "manual", + duration: d => parseInt(d) > 0, + }, + ]); + + // The local data was corrupted, and the Telemetry status reflects it. + // But the sync overwrote the bad data and was eventually a success. + // Since local data was replaced, we use records IDs to determine + // what was created and deleted. And bad local data will appear + // in the sync event as deleted. + equal(syncData.current.length, 2); + equal(syncData.created.length, 1); + equal(syncData.created[0].id, RECORD3.id); + equal(syncData.updated.length, 1); + equal(syncData.updated[0].old.serialNumber, "abc"); + equal(syncData.updated[0].new.serialNumber, RECORD2.serialNumber); + equal(syncData.deleted.length, 1); + equal(syncData.deleted[0].id, localId); + + // + // 8. + // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] (unchanged because of error) + // - timestamp: 4000 -> 6000 + // + // Check that a failing signature throws after retry, and that sync changes + // are not applied. + + const RESPONSE_ONLY_RECORD4_BAD_SIG = { + comment: "Create RECORD4", + sampleHeaders: [ + "Content-Type: application/json; charset=UTF-8", + 'ETag: "6000"', + ], + status: { status: 200, statusText: "OK" }, + responseBody: JSON.stringify({ + timestamp: 6000, + metadata: { + signatures: [ + { + x5u, + signature: "aaaaaaaaaaaaaaaaaaaaaaaa", // sig verifier wants proper length or will crash. + }, + ], + }, + changes: [ + { + id: "f765df30-b2f1-42f6-9803-7bd5a07b5098", + last_modified: 6000, + }, + ], + }), + }; + const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000 = { + ...RESPONSE_EMPTY_NO_UPDATE, + responseBody: JSON.stringify({ + timestamp: 6000, + metadata: { + signatures: [ + { + x5u, + signature: "aW52YWxpZCBzaWduYXR1cmUK", + }, + ], + }, + changes: [], + }), + }; + const allBadSigResponses = { + "GET:/v1/buckets/main/collections/signed/changeset?_expected=6000&_since=4000": + [RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000], + "GET:/v1/buckets/main/collections/signed/changeset?_expected=6000": [ + RESPONSE_ONLY_RECORD4_BAD_SIG, + ], + }; + + // Reset telemetry capture. + Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true); + Services.fog.testResetFOG(); + enableUptakeMetric(); + + registerHandlers(allBadSigResponses); + await Assert.rejects( + client.maybeSync(6000), + RemoteSettingsClient.InvalidSignatureError, + "Sync failed as expected (bad signature after retry)" + ); + + // Ensure that the failure is reflected in the accumulated telemetry: + assertTelemetryEvents([ + { + value: UptakeTelemetry.STATUS.SYNC_START, + source: TELEMETRY_SOURCE, + trigger: "manual", + }, + { + value: UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR, + source: TELEMETRY_SOURCE, + trigger: "manual", + }, + ]); + + // When signature fails after retry, the local data present before sync + // should be maintained (if its signature is valid). + ok( + arrayEqual( + (await client.get()).map(r => r.id), + [RECORD3.id, RECORD2.id] + ), + "Local records were not changed" + ); + // And local data should still be valid. + await client.get({ verifySignature: true }); // Not raising. + + // + // 9. + // - collection: [RECORD2, RECORD3] -> [] (cleared) + // - timestamp: 4000 -> 6000 + // + // Check that local data is cleared during sync if signature is not valid. + + await client.db.create({ + id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81", + last_modified: 42, + tampered: true, + }); + + await Assert.rejects( + client.maybeSync(6000), + RemoteSettingsClient.InvalidSignatureError, + "Sync failed as expected (bad signature after retry)" + ); + + // Since local data was tampered, it was cleared. + equal((await client.get()).length, 0, "Local database is now empty."); + + // + // 10. + // - collection: [RECORD2, RECORD3] -> [] (cleared) + // - timestamp: 4000 -> 6000 + // + // Check that local data is cleared during sync if signature is not valid. + + await client.db.create({ + id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81", + last_modified: 42, + tampered: true, + }); + + await Assert.rejects( + client.maybeSync(6000), + RemoteSettingsClient.InvalidSignatureError, + "Sync failed as expected (bad signature after retry)" + ); + // Since local data was tampered, it was cleared. + equal((await client.get()).length, 0, "Local database is now empty."); + + // + // 11. + // - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] + // - timestamp: 4000 -> 6000 + // + // Check that local data is restored if signature was valid before sync. + const sigCalls = []; + let i = 0; + client._verifier = { + async asyncVerifyContentSignature(serialized) { + sigCalls.push(serialized); + console.log(`verify call ${i}`); + return [ + false, // After importing changes. + true, // When checking previous local data. + false, // Still fail after retry. + true, // When checking previous local data again. + ][i++]; + }, + }; + // Create an extra record. It will have a valid signature locally + // thanks to the verifier mock. + await client.db.importChanges( + { + signatures: [{ x5u, signature: "aa" }], + }, + 4000, + [ + { + id: "extraId", + last_modified: 42, + }, + ] + ); + + equal((await client.get()).length, 1); + + // Now sync, but importing changes will have failing signature, + // and so will retry (see `sigResults`). + await Assert.rejects( + client.maybeSync(6000), + RemoteSettingsClient.InvalidSignatureError, + "Sync failed as expected (bad signature after retry)" + ); + equal(i, 4, "sync has retried as expected"); + + // Make sure that we retried on a blank DB. The extra record should + // have been deleted when we validated the signature the second time. + // Since local data was tampered, it was cleared. + ok(/extraId/.test(sigCalls[0]), "extra record when importing changes"); + ok(/extraId/.test(sigCalls[1]), "extra record when checking local"); + ok(!/extraId/.test(sigCalls[2]), "db was flushed before retry"); + ok(/extraId/.test(sigCalls[3]), "when checking local after retry"); +}); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem new file mode 100644 index 000000000..9392129f6 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICdTCCAV2gAwIBAgIUdVZo8xeazUY3Fzm6cy61HaSjFJUwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYY29sbGVjdGlvbi1zaWduZXItaW50LUNBMCIYDzIwMjQx +MTI3MDAwMDAwWhgPMjAyNzAyMDUwMDAwMDBaMCYxJDAiBgNVBAMMG2NvbGxlY3Rp +b24tc2lnbmVyLWVlLWludC1DQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABKFockM2 +K1x7GInzeRVGFaHHP7SN7oY+AikV22COJS3ktxMtqM6Y6DFTTmqcDAsJyNY5regy +BuW6gTRzoR+jMOBdqMluQ4P+J4c9qXEDviiIz/AC8Fr3Gh/dzIN0qm6pzqNIMEYw +EwYDVR0lBAwwCgYIKwYBBQUHAwMwLwYDVR0RBCgwJoIkb25lY3JsLmNvbnRlbnQt +c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBCwUAA4IBAQBVHMPKgXKX +EVVTDRw8lF0MQBHUPA2+4f9JDsaq5AexGpFqfDzFobrzZnzKQyaODeoalhASraWs +GvhXrXFeSkSLqNpw657JXuIvVOULJjr2h9uA9N837KgFosvLLIIoKs1zeXG0LSYy +NSJBSme+n8ETV2d8RJ/w575rhnheDz6GcVH0hduvQR0g7dg8ZMtdfi16sjKaJRup +coH4N8t4YhLqM4PyrEATZyTEW5jOCKCb91FWgPu0ovfQz+t3jjS7TPpUa3IYJS8V +eFUHXUAXFFgD0xCl7P/V+kopWSijZHT8mwnPfn2z9EmV5ijioIsw2MF+FACkPWa1 +o2NzujCdDAEl +-----END CERTIFICATE----- diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem.certspec b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem.certspec new file mode 100644 index 000000000..866c357c5 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_ee.pem.certspec @@ -0,0 +1,5 @@ +issuer:collection-signer-int-CA +subject:collection-signer-ee-int-CA +subjectKey:secp384r1 +extension:extKeyUsage:codeSigning +extension:subjectAlternativeName:onecrl.content-signature.mozilla.org diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem new file mode 100644 index 000000000..de3c2ff3c --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIUf3MHKqXo3U9dG7tkPLy8t7D+1o4wDQYJKoZIhvcNAQEL +BQAwKTEnMCUGA1UEAwweeHBjc2hlbGwgc2lnbmVkIGFwcHMgdGVzdCByb290MCIY +DzIwMjQxMTI3MDAwMDAwWhgPMjAyNzAyMDUwMDAwMDBaMCMxITAfBgNVBAMMGGNv +bGxlY3Rpb24tc2lnbmVyLWludC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBALqIUahEjhbWQf1utogGNhA9PBPZ6uQ1SrTs9WhXbCR7wcclqODYH72x +nAabbhqG8mvir1p1a2pkcQh6pVqnRYf3HNUknAJ+zUP8HmnQOCApk6sgw0nk27lM +wmtsDu0Vgg/xfq1pGrHTAjqLKkHup3DgDw2N/WYLK7AkkqR9uYhheZCxV5A90jvF +4LhIH6g304hD7ycW2FW3ZlqqfgKQLzp7EIAGJMwcbJetlmFbt+KWEsB1MaMMkd20 +yvf8rR0l0wnvuRcOp2jhs3svIm9p47SKlWEd7ibWJZ2rkQhONsscJAQsvxaLL+Xx +j5kXMbiz/kkj+nJRxDHVA6zaGAo17Y0CAwEAAaMlMCMwDAYDVR0TBAUwAwEB/zAT +BgNVHSUEDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAjJAZX8SZOh1H +xLqKHgt2EiFloIW3k3aAnTFQPAEUp+IBXjJdA8cFzEDSHo0dTr8jBhR8V1gwy8+Z +1i6loA0rgJUdDhUP24SqbfsdF6kcltlBfjR4lBfbZq0BClMiNDL2ZOQS4neF15fN +WXUv2UE9ooKNUjgIzXHbU/ykbwTKB7Bmsh+r5CYs9LMNF5P/QDAlDNwvZglxiyyq +61fi06lnBXff0zObSdV/8Wm0PQyHEBfNY3L94wuIFxGsX0YngO7CCtaIFIeymzob +8SvH4W0vfeqYzDFM4PfgFbDs4KnAzJS+WwWq2HRDsHMZTDZVVXVA61LXYhFEOam1 +ASCDwhnClA== +-----END CERTIFICATE----- diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem.certspec b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem.certspec new file mode 100644 index 000000000..2b3eb2d78 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_signatures/collection_signing_int.pem.certspec @@ -0,0 +1,4 @@ +issuer:xpcshell signed apps test root +subject:collection-signer-int-CA +extension:basicConstraints:cA, +extension:extKeyUsage:codeSigning diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle.js new file mode 100644 index 000000000..ba93e2d33 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle.js @@ -0,0 +1,152 @@ +const { Database } = ChromeUtils.importESModule( + "resource://services-settings/Database.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +let server; +let startupClients; + +add_setup(async () => { + // Disable signature verification of collections listed in test startup.json.mzlz4 + // since their metadata will eventually expire. + startupClients = [ + "message-groups", + "nimbus-desktop-experiments", + "search-categorization", + "tracking-protection-lists", + "query-stripping", + "cfr", + ].map(id => { + const c = RemoteSettings(id); + c.verifySignature = false; + return c; + }); + + // Setup an HTTP server to serve the 'startup.json.mzlz4' bundle. + server = new HttpServer(); + server.start(-1); + server.registerDirectory( + "/cdn/bundles/", + do_get_file("test_remote_settings_startup_bundle") + ); + server.registerPathHandler("/v1/", (request, response) => { + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: `http://localhost:${server.identity.primaryPort}/cdn/`, + }, + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v1` + ); + registerCleanupFunction(() => { + server.stop(() => {}); + Services.prefs.clearUserPref("services.settings.loglevel"); + }); +}); + +async function clear_state() { + await Database.destroy(); + RemoteSettings._ongoingExtractBundlePromise = null; +} + +add_task(async function test_bundle_is_pulled_when_get_needs_sync() { + const client = startupClients[0]; + + Assert.ok( + !(await Utils.hasLocalDump(client.bucketName, client.collectionName)), + "Client has no packaged dump" + ); + + const records = await client.get(); + + Assert.equal(records.length, 6, "Records were read from startup bundle"); +}); +add_task(clear_state); + +add_task( + async function test_signature_of_extracted_data_from_bundle_is_verified() { + const c = RemoteSettings("tracking-protection-lists"); // part of startup.json.mzlz4 + c.verifySignature = true; + + let called = null; + c.validateCollectionSignature = (records, timestamp, metadata) => { + called = { records, timestamp, metadata }; + }; + + await c.get(); + + Assert.ok(!!called.records.length); + Assert.greaterOrEqual(called.timestamp, 1694684362860); + Assert.ok(called.metadata.flags.includes("startup")); + } +); +add_task(clear_state); + +add_task(async function test_bundle_is_not_importent_when_signature_fails() { + const c = RemoteSettings("tracking-protection-lists"); // part of startup.json.mzlz4 + c.verifySignature = true; + + let called = false; + c.validateCollectionSignature = () => { + called = true; + throw new Error("boom!"); + }; + + let error; + try { + await c.get({ emptyListFallback: false }); + Assert.ok(false, ".get() should fail without network"); + } catch (e) { + error = e; + } + Assert.ok(called, "Signature was verified"); + Assert.equal( + error.name, + "UnknownCollectionError", + ".get() fails without network and with bad startup bundle" + ); +}); +add_task(clear_state); + +add_task(async function test_sync_occurs_if_collection_not_part_of_bundle() { + const c = RemoteSettings("foo"); + + let error; + try { + await c.get({ emptyListFallback: false }); + Assert.ok(false, ".get() should fail when bundle disabled"); + } catch (e) { + error = e; + } + Assert.equal( + error.name, + "UnknownCollectionError", + ".get() fails to sync data" + ); +}); +add_task(clear_state); + +add_task(async function test_several_clients_wait_for_bundle() { + // several clients calling .get() in parallel will all take content from bundle. + const results = await Promise.allSettled( + startupClients.map(c => c.get({ emptyListFallback: false })) + ); + + Assert.deepEqual( + [6, 70, 5, "UnknownCollectionError", 3, 11], + results.map(({ status, value, reason }) => + status == "fulfilled" ? value.length : reason.name + ) + ); +}); +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle/startup.json.mozlz4 b/src/zen/tests/mochitests/remote-settings/test_remote_settings_startup_bundle/startup.json.mozlz4 new file mode 100644 index 0000000000000000000000000000000000000000..978459dde9fc766e780a7fd56ea9e48f6eddaa86 GIT binary patch literal 167191 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

?n${DW^sz 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 SrMa1rg_)6=xn*r^Z7cwBpF^Pl literal 0 HcmV?d00001 diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_sync_history.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_sync_history.js new file mode 100644 index 000000000..d0ef6cfc4 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_sync_history.js @@ -0,0 +1,65 @@ +"use strict"; + +async function clear_state() { + await new SyncHistory("").clear(); +} +add_task(clear_state); + +add_task(async function test_entries_are_stored_by_source() { + const history = new SyncHistory(); + await history.store(42, "success", { pi: "3.14" }); + // Check that history is isolated by source. + await new SyncHistory("main/cfr").store(88, "error"); + + const l = await history.list(); + + Assert.deepEqual(l, [ + { + timestamp: 42, + status: "success", + infos: { pi: "3.14" }, + datetime: new Date(42), + }, + ]); +}); +add_task(clear_state); + +add_task( + async function test_old_entries_are_removed_keep_fixed_size_per_source() { + const history = new SyncHistory("settings-sync", { size: 3 }); + const anotherHistory = await new SyncHistory("main/cfr"); + + await history.store(42, "success"); + await history.store(41, "sync_error"); + await history.store(43, "up_to_date"); + + let l = await history.list(); + Assert.equal(l.length, 3); + + await history.store(44, "success"); + await anotherHistory.store(44, "success"); + + l = await history.list(); + Assert.equal(l.length, 3); + Assert.ok(!l.map(e => e.timestamp).includes(41)); + + l = await anotherHistory.list(); + Assert.equal(l.length, 1); + } +); +add_task(clear_state); + +add_task(async function test_entries_are_sorted_by_timestamp_desc() { + const history = new SyncHistory("settings-sync"); + await history.store(42, "success"); + await history.store(41, "sync_error"); + await history.store(44, "up_to_date"); + + const l = await history.list(); + + Assert.deepEqual( + l.map(e => e.timestamp), + [44, 42, 41] + ); +}); +add_task(clear_state); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_utils.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_utils.js new file mode 100644 index 000000000..fc4a73911 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_utils.js @@ -0,0 +1,299 @@ +const BinaryOutputStream = Components.Constructor( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream" +); + +const server = new HttpServer(); +server.start(-1); +registerCleanupFunction(() => server.stop(() => {})); +const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`; + +const proxyServer = new HttpServer(); +proxyServer.identity.add("http", "localhost", server.identity.primaryPort); +proxyServer.start(-1); +registerCleanupFunction(() => proxyServer.stop(() => {})); +const PROXY_PORT = proxyServer.identity.primaryPort; + +// A sequence of bytes that would become garbage if it were to be read as UTF-8: +// - 0xEF 0xBB 0xBF is a byte order mark. +// - 0xC0 on its own is invalid (it's the first byte of a 2-byte encoding). +const INVALID_UTF_8_BYTES = [0xef, 0xbb, 0xbf, 0xc0]; + +server.registerPathHandler("/binary.dat", (request, response) => { + response.setStatusLine(null, 201, "StatusLineHere"); + response.setHeader("headerName", "HeaderValue: HeaderValueEnd"); + let binaryOut = new BinaryOutputStream(response.bodyOutputStream); + binaryOut.writeByteArray([0xef, 0xbb, 0xbf, 0xc0]); +}); + +// HTTPS requests are proxied with CONNECT, but our test server is HTTP, +// which means that the proxy will receive GET http://localhost:port. +var proxiedCount = 0; +proxyServer.registerPrefixHandler("/", (request, response) => { + ++proxiedCount; + Assert.equal(request.path, "/binary.dat", `Proxy request ${proxiedCount}`); + // Close connection without sending any response. + response.seizePower(); + response.finish(); +}); + +add_task(async function test_utils_fetch_binary() { + let res = await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`); + + Assert.equal(res.status, 201, "res.status"); + Assert.equal(res.statusText, "StatusLineHere", "res.statusText"); + Assert.equal( + res.headers.get("headerName"), + "HeaderValue: HeaderValueEnd", + "Utils.fetch should return the header" + ); + + Assert.deepEqual( + Array.from(new Uint8Array(await res.arrayBuffer())), + INVALID_UTF_8_BYTES, + "Binary response body should be returned as is" + ); +}); + +add_task(async function test_utils_fetch_binary_as_text() { + let res = await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`); + Assert.deepEqual( + Array.from(await res.text(), c => c.charCodeAt(0)), + [65533], + "Interpreted as UTF-8, the response becomes garbage" + ); +}); + +add_task(async function test_utils_fetch_binary_as_json() { + let res = await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`); + await Assert.rejects( + res.json(), + /SyntaxError: JSON.parse: unexpected character/, + "Binary data is invalid JSON" + ); +}); + +add_task(async function test_utils_fetch_has_conservative() { + let channelPromise = TestUtils.topicObserved("http-on-modify-request"); + await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`); + + let channel = (await channelPromise)[0].QueryInterface(Ci.nsIHttpChannel); + + Assert.equal(channel.URI.spec, `${SERVER_BASE_URL}/binary.dat`, "URL OK"); + + let internalChannel = channel.QueryInterface(Ci.nsIHttpChannelInternal); + Assert.ok(internalChannel.beConservative, "beConservative flag is set"); +}); + +add_task(async function test_utils_fetch_has_conservative() { + let channelPromise = TestUtils.topicObserved("http-on-modify-request"); + await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`); + + let channel = (await channelPromise)[0].QueryInterface(Ci.nsIHttpChannel); + + Assert.equal(channel.URI.spec, `${SERVER_BASE_URL}/binary.dat`, "URL OK"); + + let internalChannel = channel.QueryInterface(Ci.nsIHttpChannelInternal); + Assert.ok(internalChannel.beConservative, "beConservative flag is set"); +}); + +add_task(async function test_utils_fetch_with_bad_proxy() { + Services.prefs.setIntPref("network.proxy.type", 1); + Services.prefs.setStringPref("network.proxy.http", "127.0.0.1"); + Services.prefs.setIntPref("network.proxy.http_port", PROXY_PORT); + Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true); + + // The URL that we're going to request. + const DESTINATION_URL = `${SERVER_BASE_URL}/binary.dat`; + + Assert.equal(proxiedCount, 0, "Proxy not used yet"); + { + info("Bad proxy, default prefs"); + let res = await Utils.fetch(DESTINATION_URL); + Assert.equal(res.status, 201, "Bypassed bad proxy"); + // 10 instead of 1 because of reconnect attempts after a dropped request. + Assert.equal(proxiedCount, 10, "Proxy was used by HttpChannel"); + } + + // Disables the failover logic from HttpChannel. + Services.prefs.setBoolPref("network.proxy.failover_direct", false); + proxiedCount = 0; + { + info("Bad proxy, disabled network.proxy.failover_direct"); + let res = await Utils.fetch(DESTINATION_URL); + Assert.equal(res.status, 201, "Bypassed bad proxy"); + // 10 instead of 1 because of reconnect attempts after a dropped request. + Assert.equal(proxiedCount, 10, "Proxy was used by ServiceRequest"); + } + + proxiedCount = 0; + { + info("Using internal option of Utils.fetch: bypassProxy=true"); + let res = await Utils.fetch(DESTINATION_URL, { bypassProxy: true }); + Assert.equal(res.status, 201, "Bypassed bad proxy"); + Assert.equal(proxiedCount, 0, "Not using proxy when bypassProxy=true"); + } + + // Disables the failover logic from ServiceRequest/Utils.fetch + Services.prefs.setBoolPref("network.proxy.allow_bypass", false); + proxiedCount = 0; + + info("Bad proxy, disabled network.proxy.allow_bypass"); + await Assert.rejects( + Utils.fetch(DESTINATION_URL), + /NetworkError/, + "Bad proxy request should fail without failover" + ); + // 10 instead of 1 because of reconnect attempts after a dropped request. + Assert.equal(proxiedCount, 10, "Attempted to use proxy again"); + + Services.prefs.clearUserPref("network.proxy.type"); + Services.prefs.clearUserPref("network.proxy.http"); + Services.prefs.clearUserPref("network.proxy.http_port"); + Services.prefs.clearUserPref("network.proxy.allow_hijacking_localhost"); + Services.prefs.clearUserPref("network.proxy.failover_direct"); + Services.prefs.clearUserPref("network.proxy.allow_bypass"); +}); + +add_task(async function test_base_attachment_url_depends_on_server() { + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v1` + ); + server.registerPathHandler("/v1/", (request, response) => { + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: "http://default-url.com/", + }, + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + const before = await Utils.baseAttachmentsURL(); + Assert.equal(before, "http://default-url.com/"); + + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v2` + ); + + Assert.equal( + Services.prefs.getStringPref("services.settings.server"), + Utils.SERVER_URL + ); + + server.registerPathHandler("/v2/", (request, response) => { + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: "http://some-cdn-url.org/", + }, + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + + const after = await Utils.baseAttachmentsURL(); + Assert.equal(after, "http://some-cdn-url.org/"); +}); + +add_task(async function test_base_attachment_url_reads_from_prefs() { + Services.prefs.setStringPref( + "services.settings.base_attachments_url", + `${Utils.SERVER_URL}|https://other/` + ); + Assert.equal(await Utils.baseAttachmentsURL(), "https://other/"); +}); + +add_task(async function test_base_attachment_url_is_robust_to_bad_saved_data() { + server.registerPathHandler("/v1/", (request, response) => { + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: "http://some-cdn-url.org/", + }, + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + + for (const badValue of [ + "", + "|", + Utils.SERVER_URL, + `${Utils.SERVER_URL}|https://other`, // Missing trailing slash. + `${Utils.SERVER_URL}|https://other/path`, // Missing trailing slash. + ]) { + Services.prefs.setStringPref( + "services.settings.base_attachments_url", + badValue + ); + Assert.equal( + await Utils.baseAttachmentsURL(), + "http://some-cdn-url.org/", + "Fallsback to server's" + ); + } +}); + +add_task(async function test_base_attachment_url_does_not_retry_by_default() { + Services.prefs.clearUserPref("services.settings.base_attachments_url"); + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v1` + ); + let requests = 0; + server.registerPathHandler("/v1/", (request, response) => { + requests++; + response.setStatusLine(null, 503, "Service Unavailable"); + }); + try { + await Utils.baseAttachmentsURL({ retryWaitMsec: 10 }); + Assert.ok(false, "should throw on error"); + } catch (e) {} + Assert.equal(requests, 1); +}); + +add_task(async function test_base_attachment_url_can_retry() { + Services.prefs.clearUserPref("services.settings.base_attachments_url"); + Services.prefs.setStringPref( + "services.settings.server", + `http://localhost:${server.identity.primaryPort}/v1` + ); + let requests = 0; + server.registerPathHandler("/v1/", (request, response) => { + requests++; + if (requests < 4) { + response.setStatusLine(null, 503, "Service Unavailable"); + return; + } + + response.write( + JSON.stringify({ + capabilities: { + attachments: { + base_url: "http://some-cdn-url.org/", + }, + }, + }) + ); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setStatusLine(null, 200, "OK"); + }); + Assert.equal( + await Utils.baseAttachmentsURL({ retries: 3, retryWaitMsec: 10 }), + "http://some-cdn-url.org/" + ); + Assert.equal(requests, 4); +}); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_utils_telemetry.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_utils_telemetry.js new file mode 100644 index 000000000..72fdb81b1 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_utils_telemetry.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const server = new HttpServer(); +server.start(-1); +registerCleanupFunction(() => server.stop(() => {})); +const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`; + +const proxyServer = new HttpServer(); +proxyServer.start(-1); +const PROXY_PORT = proxyServer.identity.primaryPort; +proxyServer.stop(); + +server.registerPathHandler("/destination", (request, response) => { + response.setStatusLine(null, 412); +}); + +function assertBypassTelemetryEvents(expectedEvents) { + const events = Glean.serviceRequest.bypassProxyInfo.testGetValue() ?? []; + Assert.equal(events.length, expectedEvents.length); + for (let i = 0; i < expectedEvents.length; i++) { + Assert.deepEqual(events[i].extra, { + value: expectedEvents[i].value, + ...expectedEvents[i].extra, + }); + } + Services.fog.testResetFOG(); +} + +add_task(async function test_telemetry() { + const DESTINATION_URL = `${SERVER_BASE_URL}/destination`; + + { + let res = await Utils.fetch(DESTINATION_URL); + Assert.equal(res.status, 412, "fetch without proxy succeeded"); + } + assertBypassTelemetryEvents([]); + + Services.prefs.setIntPref("network.proxy.type", 1); + Services.prefs.setStringPref("network.proxy.http", "127.0.0.1"); + Services.prefs.setIntPref("network.proxy.http_port", PROXY_PORT); + Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true); + + { + let res = await Utils.fetch(DESTINATION_URL); + Assert.equal(res.status, 412, "fetch with broken proxy succeeded"); + } + // Note: failover handled by HttpChannel, hence no failover here. + assertBypassTelemetryEvents([]); + + // Disable HttpChannel failover in favor of Utils.fetch's implementation. + Services.prefs.setBoolPref("network.proxy.failover_direct", false); + { + let res = await Utils.fetch(DESTINATION_URL); + Assert.equal(res.status, 412, "fetch succeeded with bypassProxy feature"); + } + assertBypassTelemetryEvents([ + { + category: "service_request", + method: "bypass", + object: "proxy_info", + value: "remote-settings", + extra: { + source: "prefs", + type: "manual", + }, + }, + ]); + + Services.prefs.setBoolPref("network.proxy.allow_bypass", false); + await Assert.rejects( + Utils.fetch(DESTINATION_URL), + /NetworkError/, + "Request without failover fails" + ); + assertBypassTelemetryEvents([]); +}); diff --git a/src/zen/tests/mochitests/remote-settings/test_remote_settings_worker.js b/src/zen/tests/mochitests/remote-settings/test_remote_settings_worker.js new file mode 100644 index 000000000..8fd5bf484 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_remote_settings_worker.js @@ -0,0 +1,117 @@ +add_task(async function test_canonicaljson() { + const records = [ + { id: "1", title: "title 1" }, + { id: "2", title: "title 2" }, + ]; + const timestamp = 42; + + const serialized = await RemoteSettingsWorker.canonicalStringify( + records, + timestamp + ); + + Assert.equal( + serialized, + '{"data":[{"id":"1","title":"title 1"},{"id":"2","title":"title 2"}],"last_modified":"42"}' + ); +}); + +add_task(async function test_import_json_dump_into_idb() { + if (IS_ANDROID) { + // Skip test: we don't ship remote settings dumps on Android (see package-manifest). + return; + } + const client = new RemoteSettingsClient("language-dictionaries"); + const before = await client.db.getLastModified(); + Assert.equal(before, null); + + await RemoteSettingsWorker.importJSONDump("main", "language-dictionaries"); + + const after = await client.get({ syncIfEmpty: false }); + Assert.ok(!!after.length); + let lastModifiedStamp = await client.getLastModified(); + + Assert.equal( + lastModifiedStamp, + Math.max(...after.map(record => record.last_modified)), + "Should have correct last modified timestamp" + ); + + // Force a DB close for shutdown so we can delete the DB later. + Database._shutdownHandler(); +}); + +add_task(async function test_throws_error_if_worker_fails() { + let error; + try { + await RemoteSettingsWorker.canonicalStringify(null, 42); + } catch (e) { + error = e; + } + Assert.equal(error.message.endsWith("records is null"), true); +}); + +add_task(async function test_throws_error_if_worker_fails_async() { + if (IS_ANDROID) { + // Skip test: we don't ship dump, so importJSONDump() is no-op. + return; + } + // Delete the Remote Settings database, and try to import a dump. + // This is not supported, and the error thrown asynchronously in the worker + // should be reported to the caller. + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase("remote-settings"); + request.onsuccess = () => resolve(); + request.onblocked = () => reject(new Error("Cannot delete DB")); + request.onerror = event => reject(event.target.error); + }); + let error; + try { + await RemoteSettingsWorker.importJSONDump("main", "language-dictionaries"); + } catch (e) { + error = e; + } + Assert.ok(/IndexedDB: Error accessing remote-settings/.test(error.message)); +}); + +add_task(async function test_throws_error_if_worker_crashes() { + // This simulates a crash at the worker level (not within a promise). + let error; + try { + await RemoteSettingsWorker._execute("unknown_method"); + } catch (e) { + error = e; + } + Assert.equal(error.message, "TypeError: Agent[method] is not a function"); +}); + +add_task(async function test_stops_worker_after_timeout() { + // Change the idle time. + Services.prefs.setIntPref( + "services.settings.worker_idle_max_milliseconds", + 1 + ); + // Run a task: + let serialized = await RemoteSettingsWorker.canonicalStringify([], 42); + Assert.equal(serialized, '{"data":[],"last_modified":"42"}', "API works."); + // Check that the worker gets stopped now the task is done: + await TestUtils.waitForCondition(() => !RemoteSettingsWorker.worker); + // Ensure the worker stays alive for 10 minutes instead: + Services.prefs.setIntPref( + "services.settings.worker_idle_max_milliseconds", + 600000 + ); + // Run another task: + serialized = await RemoteSettingsWorker.canonicalStringify([], 42); + Assert.equal( + serialized, + '{"data":[],"last_modified":"42"}', + "API still works." + ); + Assert.ok(RemoteSettingsWorker.worker, "Worker should stay alive a bit."); + + // Clear the pref. + Services.prefs.clearUserPref( + "services.settings.worker_idle_max_milliseconds" + ); +}); diff --git a/src/zen/tests/mochitests/remote-settings/test_shutdown_handling.js b/src/zen/tests/mochitests/remote-settings/test_shutdown_handling.js new file mode 100644 index 000000000..fbd9438a0 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_shutdown_handling.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(() => { + Services.prefs.setStringPref("services.settings.loglevel", "debug"); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("services.settings.loglevel"); + }); +}); + +add_task(async function test_shutdown_abort_after_start() { + // Start a forever transaction: + let counter = 0; + let transactionStarted; + let startedPromise = new Promise(r => { + transactionStarted = r; + }); + let promise = Database._executeIDB( + "records", + store => { + // Signal we've started. + transactionStarted(); + function makeRequest() { + if (++counter > 1000) { + Assert.ok( + false, + "We ran 1000 requests and didn't get aborted, what?" + ); + return; + } + dump("Making request " + counter + "\n"); + const request = store + .index("cid") + .openCursor(IDBKeyRange.only("foopydoo/foo")); + request.onsuccess = () => { + makeRequest(); + }; + } + makeRequest(); + }, + { mode: "readonly" } + ); + + // Wait for the transaction to start. + await startedPromise; + + Database._shutdownHandler(); // should abort the readonly transaction. + + let rejection; + await promise.catch(e => { + rejection = e; + }); + ok(rejection, "Promise should have rejected."); + + // Now clear the shutdown flag and rejection error: + Database._cancelShutdown(); + rejection = null; +}); + +add_task(async function test_shutdown_immediate_abort() { + // Now abort directly from the successful request. + let promise = Database._executeIDB( + "records", + store => { + let request = store + .index("cid") + .openCursor(IDBKeyRange.only("foopydoo/foo")); + request.onsuccess = () => { + // Abort immediately. + Database._shutdownHandler(); + request = store + .index("cid") + .openCursor(IDBKeyRange.only("foopydoo/foo")); + Assert.ok(false, "IndexedDB allowed opening a cursor after aborting?!"); + }; + }, + { mode: "readonly" } + ); + + let rejection; + // Wait for the abort + await promise.catch(e => { + rejection = e; + }); + ok(rejection, "Directly aborted promise should also have rejected."); + // Now clear the shutdown flag and rejection error: + Database._cancelShutdown(); +}); + +add_task(async function test_shutdown_worker() { + let client = new RemoteSettingsClient("language-dictionaries"); + const before = await client.db.getLastModified(); + Assert.equal(before, null); + + let records = [{}]; + let importPromise = RemoteSettingsWorker._execute( + "_test_only_import", + ["main", "language-dictionaries", records, 0], + { mustComplete: true } + ); + let stringifyPromise = RemoteSettingsWorker.canonicalStringify( + [], + [], + Date.now() + ); + // Change the idle time so we shut the worker down even though we can't + // set gShutdown from outside of the worker management code. + Services.prefs.setIntPref( + "services.settings.worker_idle_max_milliseconds", + 1 + ); + RemoteSettingsWorker._abortCancelableRequests(); + await Assert.rejects( + stringifyPromise, + /Shutdown/, + "Should have aborted the stringify request at shutdown." + ); + await Assert.rejects( + importPromise, + /shutting down/, + "Ensure imports get aborted during shutdown" + ); + const after = await client.db.getLastModified(); + Assert.equal(after, null); + await TestUtils.waitForCondition(() => !RemoteSettingsWorker.worker); + Assert.ok( + !RemoteSettingsWorker.worker, + "Worker should have been terminated." + ); +}); diff --git a/src/zen/tests/mochitests/remote-settings/test_uptake_telemetry.js b/src/zen/tests/mochitests/remote-settings/test_uptake_telemetry.js new file mode 100644 index 000000000..df7743414 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/test_uptake_telemetry.js @@ -0,0 +1,68 @@ +const { UptakeTelemetry } = ChromeUtils.importESModule( + "resource://services-settings/UptakeTelemetry.sys.mjs" +); + +add_setup(function () { + Services.fog.initializeFOG(); +}); + +function enableUptakeMetric() { + Services.fog.applyServerKnobsConfig( + JSON.stringify({ + metrics_enabled: { + "uptake.remotecontent.result.uptake_remotesettings": true, + }, + }) + ); +} + +add_task(async function test_unknown_status_is_not_reported() { + Services.fog.testResetFOG(); + enableUptakeMetric(); + + try { + await UptakeTelemetry.report("unknown-status", { source: "update-source" }); + } catch (e) {} + + Assert.equal( + null, + Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue() + ); +}); + +add_task(async function test_age_is_converted_to_string_and_reported() { + Services.fog.testResetFOG(); + enableUptakeMetric(); + const status = UptakeTelemetry.STATUS.SUCCESS; + const age = 42; + + await UptakeTelemetry.report(status, { source: "s", age }); + + const events = + Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue(); + Assert.equal(1, events.length); + Assert.deepEqual(events[0].extra, { + value: status, + source: "s", + age: `${age}`, + }); +}); + +add_task(async function test_each_status_can_be_caught_in_snapshot() { + Services.fog.testResetFOG(); + enableUptakeMetric(); + const source = "some-source"; + + for (const status of Object.values(UptakeTelemetry.STATUS)) { + await UptakeTelemetry.report(status, { source }); + } + + const events = + Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue(); + for (const status of Object.values(UptakeTelemetry.STATUS)) { + Assert.ok( + events.some(e => e.extra.value === status && e.extra.source === source), + `check events for ${status}` + ); + } +}); diff --git a/src/zen/tests/mochitests/remote-settings/xpcshell.toml b/src/zen/tests/mochitests/remote-settings/xpcshell.toml new file mode 100644 index 000000000..06c149861 --- /dev/null +++ b/src/zen/tests/mochitests/remote-settings/xpcshell.toml @@ -0,0 +1,50 @@ +[DEFAULT] +head = "../../../../services/common/tests/unit/head_global.js ../../../../services/common/tests/unit/head_helpers.js head_settings.js" +firefox-appdir = "browser" +tags = "remote-settings" +support-files = ["test_remote_settings_signatures/**"] +skip-if = [ + "appname == 'thunderbird'", # Bug 1662758 - these tests don't pass if default bucket isn't "main". +] + +["test_attachments_downloader.js"] +support-files = ["test_attachments_downloader/**"] + +["test_remote_settings.js"] + +["test_remote_settings_dump_lastmodified.js"] + +["test_remote_settings_jexl_filters.js"] + +["test_remote_settings_offline.js"] + +["test_remote_settings_older_than_local.js"] + +["test_remote_settings_poll.js"] + +["test_remote_settings_recover_broken.js"] + +["test_remote_settings_release_prefs.js"] + +["test_remote_settings_signatures.js"] + +["test_remote_settings_startup_bundle.js"] +support-files = ["test_remote_settings_startup_bundle/**"] + +["test_remote_settings_sync_history.js"] + +["test_remote_settings_utils.js"] + +["test_remote_settings_utils_telemetry.js"] +skip-if = [ + "os == 'android' && os_version == '14' && arch == 'x86_64'", # Bug 1739463 +] + +["test_remote_settings_worker.js"] + +["test_shutdown_handling.js"] + +["test_uptake_telemetry.js"] +skip-if = [ + "os == 'android'", # Bug 2042499 +] diff --git a/src/zen/tests/mochitests/services-crypto/head_helpers.js b/src/zen/tests/mochitests/services-crypto/head_helpers.js new file mode 100644 index 000000000..74d459b15 --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/head_helpers.js @@ -0,0 +1,68 @@ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +try { + // In the context of xpcshell tests, there won't be a default AppInfo + // eslint-disable-next-line mozilla/use-services + Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); +} catch (ex) { + // Make sure to provide the right OS so crypto loads the right binaries + var OS = "XPCShell"; + if (mozinfo.os == "win") { + OS = "WINNT"; + } else if (mozinfo.os == "mac") { + OS = "Darwin"; + } else { + OS = "Linux"; + } + + const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" + ); + updateAppInfo({ + name: "XPCShell", + ID: "{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}", + version: "1", + platformVersion: "", + OS, + }); +} + +function base64UrlDecode(s) { + s = s.replace(/-/g, "+"); + s = s.replace(/_/g, "/"); + + // Replace padding if it was stripped by the sender. + // See http://tools.ietf.org/html/rfc4648#section-4 + switch (s.length % 4) { + case 0: + break; // No pad chars in this case + case 2: + s += "=="; + break; // Two pad chars + case 3: + s += "="; + break; // One pad char + default: + throw new Error("Illegal base64url string!"); + } + + // With correct padding restored, apply the standard base64 decoder + return atob(s); +} + +/** + * Print some debug message to the console. All arguments will be printed, + * separated by spaces. + * + * @param [arg0, arg1, arg2, ...] + * Any number of arguments to print out + * Usage: _("Hello World") -> prints "Hello World" + * Usage: _(1, 2, 3) -> prints "1 2 3" + */ +var _ = function () { + print(Array.from(arguments).join(" ")); +}; diff --git a/src/zen/tests/mochitests/services-crypto/test_crypto_crypt.js b/src/zen/tests/mochitests/services-crypto/test_crypto_crypt.js new file mode 100644 index 000000000..badf0ef4d --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/test_crypto_crypt.js @@ -0,0 +1,226 @@ +const { WeaveCrypto } = ChromeUtils.importESModule( + "moz-src:///services/crypto/modules/WeaveCrypto.sys.mjs" +); + +var cryptoSvc = new WeaveCrypto(); + +add_task(async function test_key_memoization() { + let cryptoGlobal = cryptoSvc._getCrypto(); + let oldImport = cryptoGlobal.subtle.importKey; + if (!oldImport) { + _("Couldn't swizzle crypto.subtle.importKey; returning."); + return; + } + + let iv = cryptoSvc.generateRandomIV(); + let key = await cryptoSvc.generateRandomKey(); + let c = 0; + cryptoGlobal.subtle.importKey = function ( + format, + keyData, + algo, + extractable, + usages + ) { + c++; + return oldImport.call( + cryptoGlobal.subtle, + format, + keyData, + algo, + extractable, + usages + ); + }; + + // Encryption should cause a single counter increment. + Assert.equal(c, 0); + let cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv); + Assert.equal(c, 1); + cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv); + Assert.equal(c, 1); + + // ... as should decryption. + await cryptoSvc.decrypt(cipherText, key, iv); + await cryptoSvc.decrypt(cipherText, key, iv); + await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(c, 2); + + // Un-swizzle. + cryptoGlobal.subtle.importKey = oldImport; +}); + +// Just verify that it gets populated with the correct bytes. +add_task(async function test_makeUint8Array() { + ChromeUtils.importESModule("resource://gre/modules/ctypes.sys.mjs"); + + let item1 = cryptoSvc.makeUint8Array("abcdefghi", false); + Assert.ok(item1); + for (let i = 0; i < 8; ++i) { + Assert.equal(item1[i], "abcdefghi".charCodeAt(i)); + } +}); + +add_task(async function test_encrypt_decrypt() { + // First, do a normal run with expected usage... Generate a random key and + // iv, encrypt and decrypt a string. + var iv = cryptoSvc.generateRandomIV(); + Assert.equal(iv.length, 24); + + var key = await cryptoSvc.generateRandomKey(); + Assert.equal(key.length, 44); + + var mySecret = "bacon is a vegetable"; + var cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + Assert.equal(cipherText.length, 44); + + var clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(clearText.length, 20); + + // Did the text survive the encryption round-trip? + Assert.equal(clearText, mySecret); + Assert.notEqual(cipherText, mySecret); // just to be explicit + + // Do some more tests with a fixed key/iv, to check for reproducable results. + key = "St1tFCor7vQEJNug/465dQ=="; + iv = "oLjkfrLIOnK2bDRvW4kXYA=="; + + _("Testing small IV."); + mySecret = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="; + let shortiv = "YWJj"; + let err; + try { + await cryptoSvc.encrypt(mySecret, key, shortiv); + } catch (ex) { + err = ex; + } + Assert.ok(!!err); + + _("Testing long IV."); + let longiv = "gsgLRDaxWvIfKt75RjuvFWERt83FFsY2A0TW+0b2iVk="; + try { + await cryptoSvc.encrypt(mySecret, key, longiv); + } catch (ex) { + err = ex; + } + Assert.ok(!!err); + + // Test small input sizes + mySecret = ""; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "OGQjp6mK1a3fs9k9Ml4L3w=="); + Assert.equal(clearText, mySecret); + + mySecret = "x"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "96iMl4vhOxFUW/lVHHzVqg=="); + Assert.equal(clearText, mySecret); + + mySecret = "xx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "olpPbETRYROCSqFWcH2SWg=="); + Assert.equal(clearText, mySecret); + + mySecret = "xxx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "rRbpHGyVSZizLX/x43Wm+Q=="); + Assert.equal(clearText, mySecret); + + mySecret = "xxxx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "HeC7miVGDcpxae9RmiIKAw=="); + Assert.equal(clearText, mySecret); + + // Test non-ascii input + // ("testuser1" using similar-looking glyphs) + mySecret = String.fromCharCode(355, 277, 349, 357, 533, 537, 101, 345, 185); + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "Pj4ixByXoH3SU3JkOXaEKPgwRAWplAWFLQZkpJd5Kr4="); + Assert.equal(clearText, mySecret); + + // Tests input spanning a block boundary (AES block size is 16 bytes) + mySecret = "123456789012345"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "e6c5hwphe45/3VN/M0bMUA=="); + Assert.equal(clearText, mySecret); + + mySecret = "1234567890123456"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP1JOIQF87E2vTUvBUQnyV04="); + Assert.equal(clearText, mySecret); + + mySecret = "12345678901234567"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP5GvxWJ9+GIAS6lXw+5fHTI="); + Assert.equal(clearText, mySecret); + + key = "iz35tuIMq4/H+IYw2KTgow=="; + iv = "TJYrvva2KxvkM8hvOIvWp3=="; + mySecret = "i like pie"; + + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "DLGx8BWqSCLGG7i/xwvvxg=="); + Assert.equal(clearText, mySecret); + + key = "c5hG3YG+NC61FFy8NOHQak1ZhMEWO79bwiAfar2euzI="; + iv = "gsgLRDaxWvIfKt75RjuvFW=="; + mySecret = "i like pie"; + + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "o+ADtdMd8ubzNWurS6jt0Q=="); + Assert.equal(clearText, mySecret); + + key = "St1tFCor7vQEJNug/465dQ=="; + iv = "oLjkfrLIOnK2bDRvW4kXYA=="; + mySecret = "does thunder read testcases?"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + Assert.equal(cipherText, "T6fik9Ros+DB2ablH9zZ8FWZ0xm/szSwJjIHZu7sjPs="); + + var badkey = "badkeybadkeybadkeybadk=="; + var badiv = "badivbadivbadivbadivbad="; + var badcipher = "crapinputcrapinputcrapinputcrapinputcrapinp="; + var failure; + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, badkey, iv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, key, badiv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, badkey, badiv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(badcipher, key, iv); + } catch (e) { + failure = true; + } + Assert.ok(failure); +}); diff --git a/src/zen/tests/mochitests/services-crypto/test_crypto_random.js b/src/zen/tests/mochitests/services-crypto/test_crypto_random.js new file mode 100644 index 000000000..55d0037b6 --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/test_crypto_random.js @@ -0,0 +1,52 @@ +const { WeaveCrypto } = ChromeUtils.importESModule( + "moz-src:///services/crypto/modules/WeaveCrypto.sys.mjs" +); + +var cryptoSvc = new WeaveCrypto(); + +add_task(async function test_crypto_random() { + if (this.gczeal) { + _("Running crypto random tests with gczeal(2)."); + gczeal(2); + } + + // Test salt generation. + var salt; + + salt = cryptoSvc.generateRandomBytes(0); + Assert.equal(salt.length, 0); + salt = cryptoSvc.generateRandomBytes(1); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(2); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(3); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(4); + Assert.equal(salt.length, 8); + salt = cryptoSvc.generateRandomBytes(8); + Assert.equal(salt.length, 12); + + // sanity check to make sure salts seem random + var salt2 = cryptoSvc.generateRandomBytes(8); + Assert.equal(salt2.length, 12); + Assert.notEqual(salt, salt2); + + salt = cryptoSvc.generateRandomBytes(1024); + Assert.equal(salt.length, 1368); + salt = cryptoSvc.generateRandomBytes(16); + Assert.equal(salt.length, 24); + + // Test random key generation + var keydata, keydata2, iv; + + keydata = await cryptoSvc.generateRandomKey(); + Assert.equal(keydata.length, 44); + keydata2 = await cryptoSvc.generateRandomKey(); + Assert.notEqual(keydata, keydata2); // sanity check for randomness + iv = cryptoSvc.generateRandomIV(); + Assert.equal(iv.length, 24); + + if (this.gczeal) { + gczeal(0); + } +}); diff --git a/src/zen/tests/mochitests/services-crypto/test_jwcrypto.js b/src/zen/tests/mochitests/services-crypto/test_jwcrypto.js new file mode 100644 index 000000000..04a30cd25 --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/test_jwcrypto.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs", +}); + +// Enable logging from jwcrypto.sys.mjs. +Services.prefs.setStringPref("services.crypto.jwcrypto.log.level", "Debug"); + +add_task(async function test_jwe_roundtrip_ecdh_es_encryption() { + const plaintext = crypto.getRandomValues(new Uint8Array(123)); + const remoteKey = await crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveKey"] + ); + const remoteJWK = await crypto.subtle.exportKey("jwk", remoteKey.publicKey); + delete remoteJWK.key_ops; + const jwe = await jwcrypto.generateJWE(remoteJWK, plaintext); + const decrypted = await jwcrypto.decryptJWE(jwe, remoteKey.privateKey); + Assert.deepEqual(plaintext, decrypted); +}); + +add_task(async function test_jwe_header_includes_key_id() { + const plaintext = crypto.getRandomValues(new Uint8Array(123)); + const remoteKey = await crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveKey"] + ); + const remoteJWK = await crypto.subtle.exportKey("jwk", remoteKey.publicKey); + delete remoteJWK.key_ops; + remoteJWK.kid = "key identifier"; + const jwe = await jwcrypto.generateJWE(remoteJWK, plaintext); + let [header /* other items deliberately ignored */] = jwe.split("."); + header = JSON.parse( + new TextDecoder().decode( + ChromeUtils.base64URLDecode(header, { padding: "reject" }) + ) + ); + Assert.equal(header.kid, "key identifier"); +}); diff --git a/src/zen/tests/mochitests/services-crypto/test_utils_hawk.js b/src/zen/tests/mochitests/services-crypto/test_utils_hawk.js new file mode 100644 index 000000000..6faef5d68 --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/test_utils_hawk.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoUtils } = ChromeUtils.importESModule( + "moz-src:///services/crypto/modules/utils.sys.mjs" +); + +function run_test() { + initTestLogging(); + + run_next_test(); +} + +add_task(async function test_hawk() { + let compute = CryptoUtils.computeHAWK; + + let method = "POST"; + let ts = 1353809207; + let nonce = "Ygvqdz"; + + let credentials = { + id: "123456", + key: "2983d45yun89q", + }; + + let uri_https = CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow" + ); + let opts = { + credentials, + ext: "Bazinga!", + ts, + nonce, + payload: "something to write about", + contentType: "text/plain", + }; + + let result = await compute(uri_https, method, opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + Assert.equal(result.artifacts.ts, ts); + Assert.equal(result.artifacts.nonce, nonce); + Assert.equal(result.artifacts.method, method); + Assert.equal(result.artifacts.resource, "/somewhere/over/the/rainbow"); + Assert.equal(result.artifacts.host, "example.net"); + Assert.equal(result.artifacts.port, 443); + Assert.equal( + result.artifacts.hash, + "2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=" + ); + Assert.equal(result.artifacts.ext, "Bazinga!"); + + let opts_noext = { + credentials, + ts, + nonce, + payload: "something to write about", + contentType: "text/plain", + }; + result = await compute(uri_https, method, opts_noext); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'mac="HTgtd0jPI6E4izx8e4OHdO36q00xFCU0FolNq3RiCYs="' + ); + Assert.equal(result.artifacts.ts, ts); + Assert.equal(result.artifacts.nonce, nonce); + Assert.equal(result.artifacts.method, method); + Assert.equal(result.artifacts.resource, "/somewhere/over/the/rainbow"); + Assert.equal(result.artifacts.host, "example.net"); + Assert.equal(result.artifacts.port, 443); + Assert.equal( + result.artifacts.hash, + "2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=" + ); + + /* Leaving optional fields out should work, although of course then we can't + * assert much about the resulting hashes. The resulting header should look + * roughly like: + * Hawk id="123456", ts="1378764955", nonce="QkynqsrS44M=", mac="/C5NsoAs2fVn+d/I5wMfwe2Gr1MZyAJ6pFyDHG4Gf9U=" + */ + + result = await compute(uri_https, method, { credentials }); + let fields = result.field.split(" "); + Assert.equal(fields[0], "Hawk"); + Assert.equal(fields[1], 'id="123456",'); // from creds.id + Assert.ok(fields[2].startsWith('ts="')); + /* The HAWK spec calls for seconds-since-epoch, not ms-since-epoch. + * Warning: this test will fail in the year 33658, and for time travellers + * who journey earlier than 2001. Please plan accordingly. */ + Assert.greater(result.artifacts.ts, 1000 * 1000 * 1000); + Assert.less(result.artifacts.ts, 1000 * 1000 * 1000 * 1000); + Assert.ok(fields[3].startsWith('nonce="')); + Assert.equal(fields[3].length, 'nonce="12345678901=",'.length); + Assert.equal(result.artifacts.nonce.length, "12345678901=".length); + + let result2 = await compute(uri_https, method, { credentials }); + Assert.notEqual(result.artifacts.nonce, result2.artifacts.nonce); + + /* Using an upper-case URI hostname shouldn't affect the hash. */ + + let uri_https_upper = CommonUtils.makeURI( + "https://EXAMPLE.NET/somewhere/over/the/rainbow" + ); + result = await compute(uri_https_upper, method, opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + + /* Using a lower-case method name shouldn't affect the hash. */ + result = await compute(uri_https_upper, method.toLowerCase(), opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + + /* The localtimeOffsetMsec field should be honored. HAWK uses this to + * compensate for clock skew between client and server: if the request is + * rejected with a timestamp out-of-range error, the error includes the + * server's time, and the client computes its clock offset and tries again. + * Clients can remember this offset for a while. + */ + + result = await compute(uri_https, method, { + credentials, + now: 1378848968650, + }); + Assert.equal(result.artifacts.ts, 1378848968); + + result = await compute(uri_https, method, { + credentials, + now: 1378848968650, + localtimeOffsetMsec: 1000 * 1000, + }); + Assert.equal(result.artifacts.ts, 1378848968 + 1000); + + /* Search/query-args in URIs should be included in the hash. */ + let makeURI = CommonUtils.makeURI; + result = await compute(makeURI("http://example.net/path"), method, opts); + Assert.equal(result.artifacts.resource, "/path"); + Assert.equal( + result.artifacts.mac, + "WyKHJjWaeYt8aJD+H9UeCWc0Y9C+07ooTmrcrOW4MPI=" + ); + + result = await compute(makeURI("http://example.net/path/"), method, opts); + Assert.equal(result.artifacts.resource, "/path/"); + Assert.equal( + result.artifacts.mac, + "xAYp2MgZQFvTKJT9u8nsvMjshCRRkuaeYqQbYSFp9Qw=" + ); + + result = await compute( + makeURI("http://example.net/path?query=search"), + method, + opts + ); + Assert.equal(result.artifacts.resource, "/path?query=search"); + Assert.equal( + result.artifacts.mac, + "C06a8pip2rA4QkBiosEmC32WcgFcW/R5SQC6kUWyqho=" + ); + + /* Test handling of the payload, which is supposed to be a bytestring + (String with codepoints from U+0000 to U+00FF, pre-encoded). */ + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal(result.artifacts.hash, undefined); + Assert.equal( + result.artifacts.mac, + "S3f8E4hAURAqJxOlsYugkPZxLoRYrClgbSQ/3FmKMbY=" + ); + + // Empty payload changes nothing. + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: null, + }); + Assert.equal(result.artifacts.hash, undefined); + Assert.equal( + result.artifacts.mac, + "S3f8E4hAURAqJxOlsYugkPZxLoRYrClgbSQ/3FmKMbY=" + ); + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "hello", + }); + Assert.equal( + result.artifacts.hash, + "uZJnFj0XVBA6Rs1hEvdIDf8NraM0qRNXdFbR3NEQbVA=" + ); + Assert.equal( + result.artifacts.mac, + "pLsHHzngIn5CTJhWBtBr+BezUFvdd/IadpTp/FYVIRM=" + ); + + // update, utf-8 payload + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "andré@example.org", // non-ASCII + }); + Assert.equal( + result.artifacts.hash, + "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=" + ); + Assert.equal( + result.artifacts.mac, + "2B++3x5xfHEZbPZGDiK3IwfPZctkV4DUr2ORg1vIHvk=" + ); + + /* If "hash" is provided, "payload" is ignored. */ + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + hash: "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=", + payload: "something else", + }); + Assert.equal( + result.artifacts.hash, + "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=" + ); + Assert.equal( + result.artifacts.mac, + "2B++3x5xfHEZbPZGDiK3IwfPZctkV4DUr2ORg1vIHvk=" + ); + + // the payload "hash" is also non-urlsafe base64 (+/) + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "something else", + }); + Assert.equal( + result.artifacts.hash, + "lERFXr/IKOaAoYw+eBseDUSwmqZTX0uKZpcWLxsdzt8=" + ); + Assert.equal( + result.artifacts.mac, + "jiZuhsac35oD7IdcblhFncBr8tJFHcwWLr8NIYWr9PQ=" + ); + + /* Test non-ascii hostname. HAWK (via the node.js "url" module) punycodes + * "ëxample.net" into "xn--xample-ova.net" before hashing. I still think + * punycode was a bad joke that got out of the lab and into a spec. + */ + + result = await compute(makeURI("http://ëxample.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "pILiHl1q8bbNQIdaaLwAFyaFmDU70MGehFuCs3AA5M0=" + ); + Assert.equal(result.artifacts.host, "xn--xample-ova.net"); + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + ext: 'backslash=\\ quote=" EOF', + }); + Assert.equal( + result.artifacts.mac, + "BEMW76lwaJlPX4E/dajF970T6+GzWvaeyLzUt8eOTOc=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ext="backslash=\\\\ quote=\\" EOF", mac="BEMW76lwaJlPX4E/dajF970T6+GzWvaeyLzUt8eOTOc="' + ); + + result = await compute(makeURI("http://example.net:1234/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", mac="6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE="' + ); + + /* HAWK (the node.js library) uses a URL parser which stores the "port" + * field as a string, but makeURI() gives us an integer. So we'll diverge + * on ports with a leading zero. This test vector would fail on the node.js + * library (HAWK-1.1.1), where they get a MAC of + * "T+GcAsDO8GRHIvZLeepSvXLwDlFJugcZroAy9+uAtcw=". I think HAWK should be + * updated to do what we do here, so port="01234" should get the same hash + * as port="1234". + */ + result = await compute(makeURI("http://example.net:01234/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", mac="6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE="' + ); +}); + +add_test(function test_strip_header_attributes() { + let strip = CryptoUtils.stripHeaderAttributes; + + Assert.equal(strip(undefined), ""); + Assert.equal(strip("text/plain"), "text/plain"); + Assert.equal(strip("TEXT/PLAIN"), "text/plain"); + Assert.equal(strip(" text/plain "), "text/plain"); + Assert.equal(strip("text/plain ; charset=utf-8 "), "text/plain"); + + run_next_test(); +}); diff --git a/src/zen/tests/mochitests/services-crypto/test_utils_httpmac.js b/src/zen/tests/mochitests/services-crypto/test_utils_httpmac.js new file mode 100644 index 000000000..ced519b6a --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/test_utils_httpmac.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoUtils } = ChromeUtils.importESModule( + "moz-src:///services/crypto/modules/utils.sys.mjs" +); + +add_test(function setup() { + initTestLogging(); + run_next_test(); +}); + +add_task(async function test_sha1() { + _("Ensure HTTP MAC SHA1 generation works as expected."); + + let id = "vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7"; + let key = "b8u1cc5iiio5o319og7hh8faf2gi5ym4aq0zwf112cv1287an65fudu5zj7zo7dz"; + let ts = 1329181221; + let method = "GET"; + let nonce = "wGX71"; + let uri = CommonUtils.makeURI("http://10.250.2.176/alias/"); + + let result = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, { + ts, + nonce, + }); + + Assert.equal(btoa(result.mac), "jzh5chjQc2zFEvLbyHnPdX11Yck="); + + Assert.equal( + result.getHeader(), + 'MAC id="vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7", ' + + 'ts="1329181221", nonce="wGX71", mac="jzh5chjQc2zFEvLbyHnPdX11Yck="' + ); + + let ext = "EXTRA DATA; foo,bar=1"; + + result = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, { + ts, + nonce, + ext, + }); + Assert.equal(btoa(result.mac), "bNf4Fnt5k6DnhmyipLPkuZroH68="); + Assert.equal( + result.getHeader(), + 'MAC id="vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7", ' + + 'ts="1329181221", nonce="wGX71", mac="bNf4Fnt5k6DnhmyipLPkuZroH68=", ' + + 'ext="EXTRA DATA; foo,bar=1"' + ); +}); + +add_task(async function test_nonce_length() { + _("Ensure custom nonce lengths are honoured."); + + function get_mac(length) { + let uri = CommonUtils.makeURI("http://example.com/"); + return CryptoUtils.computeHTTPMACSHA1("foo", "bar", "GET", uri, { + nonce_bytes: length, + }); + } + + let result = await get_mac(12); + Assert.equal(12, atob(result.nonce).length); + + result = await get_mac(2); + Assert.equal(2, atob(result.nonce).length); + + result = await get_mac(0); + Assert.equal(8, atob(result.nonce).length); + + result = await get_mac(-1); + Assert.equal(8, atob(result.nonce).length); +}); diff --git a/src/zen/tests/mochitests/services-crypto/xpcshell.toml b/src/zen/tests/mochitests/services-crypto/xpcshell.toml new file mode 100644 index 000000000..55529eb2c --- /dev/null +++ b/src/zen/tests/mochitests/services-crypto/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] +head = "head_helpers.js ../../../../services/common/tests/unit/head_helpers.js" +firefox-appdir = "browser" +support-files = ["!/services/common/tests/unit/head_helpers.js"] + +["test_crypto_crypt.js"] + +["test_crypto_random.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_jwcrypto.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_utils_hawk.js"] + +["test_utils_httpmac.js"] diff --git a/src/zen/tests/mochitests/sync/addon1-search.json b/src/zen/tests/mochitests/sync/addon1-search.json new file mode 100644 index 000000000..55f8af885 --- /dev/null +++ b/src/zen/tests/mochitests/sync/addon1-search.json @@ -0,0 +1,21 @@ +{ + "next": null, + "results": [ + { + "name": "Non-Restartless Test Extension", + "type": "extension", + "guid": "addon1@tests.mozilla.org", + "current_version": { + "version": "1.0", + "files": [ + { + "platform": "all", + "size": 485, + "url": "http://127.0.0.1:8888/addon1.xpi" + } + ] + }, + "last_updated": "2011-09-05T20:42:09Z" + } + ] +} diff --git a/src/zen/tests/mochitests/sync/bootstrap1-search.json b/src/zen/tests/mochitests/sync/bootstrap1-search.json new file mode 100644 index 000000000..8cd1cf43e --- /dev/null +++ b/src/zen/tests/mochitests/sync/bootstrap1-search.json @@ -0,0 +1,21 @@ +{ + "next": null, + "results": [ + { + "name": "Restartless Test Extension", + "type": "extension", + "guid": "bootstrap1@tests.mozilla.org", + "current_version": { + "version": "1.0", + "files": [ + { + "platform": "all", + "size": 485, + "url": "http://127.0.0.1:8888/bootstrap1.xpi" + } + ] + }, + "last_updated": "2011-09-05T20:42:09Z" + } + ] +} diff --git a/src/zen/tests/mochitests/sync/head_appinfo.js b/src/zen/tests/mochitests/sync/head_appinfo.js new file mode 100644 index 000000000..79379ccea --- /dev/null +++ b/src/zen/tests/mochitests/sync/head_appinfo.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// Required to avoid failures. +do_get_profile(); + +// Init FormHistoryStartup and pretend we opened a profile. +var fhs = Cc["@mozilla.org/satchel/form-history-startup;1"].getService( + Ci.nsIObserver +); +fhs.observe(null, "profile-after-change", null); + +// An app is going to have some prefs set which xpcshell tests don't. +Services.prefs.setStringPref( + "identity.sync.tokenserver.uri", + "http://token-server" +); + +// Make sure to provide the right OS so crypto loads the right binaries +function getOS() { + switch (mozinfo.os) { + case "win": + return "WINNT"; + case "mac": + return "Darwin"; + default: + return "Linux"; + } +} + +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "XPCShell", + ID: "xpcshell@tests.mozilla.org", + version: "1", + platformVersion: "", + OS: getOS(), +}); + +// Register resource aliases. Normally done in SyncComponents.manifest. +function addResourceAlias() { + const resProt = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + for (let s of ["common", "sync", "crypto"]) { + let uri = Services.io.newURI("resource://gre/modules/services-" + s + "/"); + resProt.setSubstitution("services-" + s, uri); + } +} +addResourceAlias(); diff --git a/src/zen/tests/mochitests/sync/head_errorhandler_common.js b/src/zen/tests/mochitests/sync/head_errorhandler_common.js new file mode 100644 index 000000000..fcf44ec43 --- /dev/null +++ b/src/zen/tests/mochitests/sync/head_errorhandler_common.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from head_appinfo.js */ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ +/* import-globals-from head_helpers.js */ +/* import-globals-from head_http_server.js */ + +// This file expects Service to be defined in the global scope when EHTestsCommon +// is used (from service.js). +/* global Service */ + +var { Changeset, EngineManager, Store, SyncEngine, Tracker, LegacyTracker } = + ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); +var { + ABORT_SYNC_COMMAND, + CLIENT_NOT_CONFIGURED, + CREDENTIALS_CHANGED, + DEFAULT_DOWNLOAD_BATCH_SIZE, + DEFAULT_GUID_FETCH_BATCH_SIZE, + DEFAULT_KEYBUNDLE_NAME, + DEVICE_TYPE_DESKTOP, + DEVICE_TYPE_MOBILE, + ENGINE_APPLY_FAIL, + ENGINE_BATCH_INTERRUPTED, + ENGINE_DOWNLOAD_FAIL, + ENGINE_SUCCEEDED, + ENGINE_UNKNOWN_FAIL, + ENGINE_UPLOAD_FAIL, + HMAC_EVENT_INTERVAL, + IDLE_OBSERVER_BACK_DELAY, + LOGIN_FAILED, + LOGIN_FAILED_INVALID_PASSPHRASE, + LOGIN_FAILED_LOGIN_REJECTED, + LOGIN_FAILED_NETWORK_ERROR, + LOGIN_FAILED_NO_PASSPHRASE, + LOGIN_FAILED_NO_USERNAME, + LOGIN_FAILED_SERVER_ERROR, + LOGIN_SUCCEEDED, + MASTER_PASSWORD_LOCKED, + MASTER_PASSWORD_LOCKED_RETRY_INTERVAL, + MAXIMUM_BACKOFF_INTERVAL, + MAX_ERROR_COUNT_BEFORE_BACKOFF, + MAX_HISTORY_DOWNLOAD, + MAX_HISTORY_UPLOAD, + METARECORD_DOWNLOAD_FAIL, + MINIMUM_BACKOFF_INTERVAL, + MULTI_DEVICE_THRESHOLD, + NO_SYNC_NODE_FOUND, + NO_SYNC_NODE_INTERVAL, + OVER_QUOTA, + PREFS_BRANCH, + RESPONSE_OVER_QUOTA, + SCORE_INCREMENT_MEDIUM, + SCORE_INCREMENT_SMALL, + SCORE_INCREMENT_XLARGE, + SCORE_UPDATE_DELAY, + SERVER_MAINTENANCE, + SINGLE_USER_THRESHOLD, + SQLITE_MAX_VARIABLE_NUMBER, + STATUS_DISABLED, + STATUS_OK, + STORAGE_VERSION, + SYNC_FAILED, + SYNC_FAILED_PARTIAL, + SYNC_KEY_DECODED_LENGTH, + SYNC_KEY_ENCODED_LENGTH, + SYNC_SUCCEEDED, + URI_LENGTH_MAX, + VERSION_OUT_OF_DATE, + WEAVE_VERSION, + kFirefoxShuttingDown, + kFirstSyncChoiceNotMade, + kSyncBackoffNotMet, + kSyncMasterPasswordLocked, + kSyncNetworkOffline, + kSyncNotConfigured, + kSyncWeaveDisabled, +} = ChromeUtils.importESModule("resource://services-sync/constants.sys.mjs"); +var { BulkKeyBundle, SyncKeyBundle } = ChromeUtils.importESModule( + "resource://services-sync/keys.sys.mjs" +); + +// Common code for test_errorhandler_{1,2}.js -- pulled out to make it less +// monolithic and take less time to execute. +const EHTestsCommon = { + service_unavailable(request, response) { + let body = "Service Unavailable"; + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader("Retry-After", "42"); + response.bodyOutputStream.write(body, body.length); + }, + + async sync_httpd_setup() { + let clientsEngine = Service.clientsEngine; + let clientsSyncID = await clientsEngine.resetLocalSyncID(); + let catapultEngine = Service.engineManager.get("catapult"); + let catapultSyncID = await catapultEngine.resetLocalSyncID(); + let global = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { + clients: { version: clientsEngine.version, syncID: clientsSyncID }, + catapult: { version: catapultEngine.version, syncID: catapultSyncID }, + }, + }); + let clientsColl = new ServerCollection({}, true); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + let handler_401 = httpd_handler(401, "Unauthorized"); + return httpd_setup({ + // Normal server behaviour. + "/1.1/johndoe/storage/meta/global": upd("meta", global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()), + + // Credentials are wrong or node reallocated. + "/1.1/janedoe/storage/meta/global": handler_401, + "/1.1/janedoe/info/collections": handler_401, + + // Maintenance or overloaded (503 + Retry-After) at info/collections. + "/1.1/broken.info/info/collections": EHTestsCommon.service_unavailable, + + // Maintenance or overloaded (503 + Retry-After) at meta/global. + "/1.1/broken.meta/storage/meta/global": EHTestsCommon.service_unavailable, + "/1.1/broken.meta/info/collections": collectionsHelper.handler, + + // Maintenance or overloaded (503 + Retry-After) at crypto/keys. + "/1.1/broken.keys/storage/meta/global": upd("meta", global.handler()), + "/1.1/broken.keys/info/collections": collectionsHelper.handler, + "/1.1/broken.keys/storage/crypto/keys": EHTestsCommon.service_unavailable, + + // Maintenance or overloaded (503 + Retry-After) at wiping collection. + "/1.1/broken.wipe/info/collections": collectionsHelper.handler, + "/1.1/broken.wipe/storage/meta/global": upd("meta", global.handler()), + "/1.1/broken.wipe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/broken.wipe/storage": EHTestsCommon.service_unavailable, + "/1.1/broken.wipe/storage/clients": upd("clients", clientsColl.handler()), + "/1.1/broken.wipe/storage/catapult": EHTestsCommon.service_unavailable, + }); + }, + + CatapultEngine: (function () { + function CatapultEngine() { + SyncEngine.call(this, "Catapult", Service); + } + CatapultEngine.prototype = { + exception: null, // tests fill this in + async _sync() { + if (this.exception) { + throw this.exception; + } + }, + }; + Object.setPrototypeOf(CatapultEngine.prototype, SyncEngine.prototype); + + return CatapultEngine; + })(), + + async generateCredentialsChangedFailure() { + // Make sync fail due to changed credentials. We simply re-encrypt + // the keys with a different Sync Key, without changing the local one. + let newSyncKeyBundle = new BulkKeyBundle("crypto"); + await newSyncKeyBundle.generateRandom(); + let keys = Service.collectionKeys.asWBO(); + await keys.encrypt(newSyncKeyBundle); + return keys.upload(Service.resource(Service.cryptoKeysURL)); + }, + + async setUp(server) { + syncTestLogging(); + await configureIdentity({ username: "johndoe" }, server); + return EHTestsCommon.generateAndUploadKeys(); + }, + + async generateAndUploadKeys() { + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + let response = await serverKeys.upload( + Service.resource(Service.cryptoKeysURL) + ); + return response.success; + }, +}; diff --git a/src/zen/tests/mochitests/sync/head_helpers.js b/src/zen/tests/mochitests/sync/head_helpers.js new file mode 100644 index 000000000..d9383db21 --- /dev/null +++ b/src/zen/tests/mochitests/sync/head_helpers.js @@ -0,0 +1,621 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from head_appinfo.js */ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ +/* import-globals-from head_errorhandler_common.js */ +/* import-globals-from head_http_server.js */ + +// This file expects Service to be defined in the global scope when EHTestsCommon +// is used (from service.js). +/* global Service */ + +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +var { Async } = ChromeUtils.importESModule( + "resource://services-common/async.sys.mjs" +); +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); +var { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +var { SerializableSet, Svc, Utils, getChromeWindow } = + ChromeUtils.importESModule("resource://services-sync/util.sys.mjs"); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { PlacesSyncUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesSyncUtils.sys.mjs" +); +var { ObjectUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ObjectUtils.sys.mjs" +); +var { + MockFxaStorageManager, + SyncTestingInfrastructure, + configureFxAccountIdentity, + configureIdentity, + encryptPayload, + getLoginTelemetryScalar, + makeFxAccountsInternalMock, + makeIdentityConfig, + promiseNamedTimer, + promiseZeroTimer, + sumHistogram, + syncTestLogging, + waitForZeroTimer, +} = ChromeUtils.importESModule( + "resource://testing-common/services/sync/utils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +add_setup(async function head_setup() { + // Initialize logging. This will sometimes be reset by a pref reset, + // so it's also called as part of SyncTestingInfrastructure(). + syncTestLogging(); + // If a test imports Service, make sure it is initialized first. + if (typeof Service !== "undefined") { + await Service.promiseInitialized; + } +}); + +// This is needed for loadAddonTestFunctions(). +var gGlobalScope = this; + +function ExtensionsTestPath(path) { + if (path[0] != "/") { + throw Error("Path must begin with '/': " + path); + } + + return "../../../../toolkit/mozapps/extensions/test/xpcshell" + path; +} + +function webExtensionsTestPath(path) { + if (path[0] != "/") { + throw Error("Path must begin with '/': " + path); + } + + return "../../../../toolkit/components/extensions/test/xpcshell" + path; +} + +/** + * Loads the WebExtension test functions by importing its test file. + */ +function loadWebExtensionTestFunctions() { + /* import-globals-from ../../../../toolkit/components/extensions/test/xpcshell/head_sync.js */ + const path = webExtensionsTestPath("/head_sync.js"); + let file = do_get_file(path); + let uri = Services.io.newFileURI(file); + Services.scriptloader.loadSubScript(uri.spec, gGlobalScope); +} + +/** + * Installs an add-on from an addonInstall + * + * @param install addonInstall instance to install + */ +async function installAddonFromInstall(install) { + await install.install(); + + Assert.notEqual(null, install.addon); + Assert.notEqual(null, install.addon.syncGUID); + + return install.addon; +} + +/** + * Convenience function to install an add-on from the extensions unit tests. + * + * @param file + * Add-on file to install. + * @param reconciler + * addons reconciler, if passed we will wait on the events to be + * processed before resolving + * @return addon object that was installed + */ +async function installAddon(file, reconciler = null) { + let install = await AddonManager.getInstallForFile(file); + Assert.notEqual(null, install); + const addon = await installAddonFromInstall(install); + if (reconciler) { + await reconciler.queueCaller.promiseCallsComplete(); + } + return addon; +} + +/** + * Convenience function to uninstall an add-on. + * + * @param addon + * Addon instance to uninstall + * @param reconciler + * addons reconciler, if passed we will wait on the events to be + * processed before resolving + */ +async function uninstallAddon(addon, reconciler = null) { + const uninstallPromise = new Promise(res => { + let listener = { + onUninstalled(uninstalled) { + if (uninstalled.id == addon.id) { + AddonManager.removeAddonListener(listener); + res(uninstalled); + } + }, + }; + AddonManager.addAddonListener(listener); + }); + addon.uninstall(); + await uninstallPromise; + if (reconciler) { + await reconciler.queueCaller.promiseCallsComplete(); + } +} + +async function generateNewKeys(collectionKeys, collections = null) { + let wbo = await collectionKeys.generateNewKeysWBO(collections); + let modified = new_timestamp(); + collectionKeys.setContents(wbo.cleartext, modified); +} + +// Helpers for testing open tabs. +// These reflect part of the internal structure of TabEngine, +// and stub part of Service.wm. + +function mockGetTabState(tab) { + return tab; +} + +function mockGetOrderedNonPrivateWindows(urls) { + let tabs = []; + let win = { + gBrowser: { + tabs, + }, + }; + + let lastAccessed = 2000; + for (let url of urls) { + tabs.push({ + linkedBrowser: { + currentURI: Services.io.newURI(url), + contentTitle: "title", + }, + lastAccessed, + }); + lastAccessed += 1000; + } + + return [win]; +} + +// Helper function to get the sync telemetry and add the typically used test +// engine names to its list of allowed engines. +function get_sync_test_telemetry() { + let { SyncTelemetry } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" + ); + SyncTelemetry.tryRefreshDevices = function () {}; + let testEngines = ["rotary", "steam", "sterling", "catapult", "nineties"]; + for (let engineName of testEngines) { + SyncTelemetry.allowedEngines.add(engineName); + } + SyncTelemetry.submissionInterval = -1; + return SyncTelemetry; +} + +function assert_valid_ping(record) { + if (record && (record.why != "shutdown" || !!record.syncs.length)) { + record.syncs.forEach(p => { + lessOrEqual(p.when, Date.now()); + }); + } +} + +function assert_success_sync(record) { + ok(!record.failureReason, JSON.stringify(record.failureReason)); + equal(undefined, record.status); + greater(record.engines.length, 0); + for (let e of record.engines) { + ok(!e.failureReason); + equal(undefined, e.status); + if (e.validation) { + equal(undefined, e.validation.problems); + equal(undefined, e.validation.failureReason); + } + if (e.outgoing) { + for (let o of e.outgoing) { + equal(undefined, o.failed); + notEqual(undefined, o.sent); + } + } + if (e.incoming) { + equal(undefined, e.incoming.failed); + equal(undefined, e.incoming.newFailed); + notEqual(undefined, e.incoming.applied || e.incoming.reconciled); + } + } +} + +// Asserts that `ping` is a ping that doesn't contain any failure information +function assert_success_ping(ping) { + ok(!!ping); + assert_valid_ping(ping); + ping.syncs.forEach(assert_success_sync); +} + +// Hooks into telemetry to validate all pings after calling. +function validate_all_future_pings() { + let telem = get_sync_test_telemetry(); + telem.submit = assert_valid_ping; +} + +function wait_for_pings(expectedPings) { + return new Promise(resolve => { + let telem = get_sync_test_telemetry(); + let oldSubmit = telem.submit; + let pings = []; + telem.submit = function (record) { + pings.push(record); + if (pings.length == expectedPings) { + telem.submit = oldSubmit; + resolve(pings); + } + }; + }); +} + +async function wait_for_ping(callback, allowErrorPings, getFullPing = false) { + let pingsPromise = wait_for_pings(1); + await callback(); + let [record] = await pingsPromise; + if (allowErrorPings) { + assert_valid_ping(record); + } else { + assert_success_ping(record); + } + if (getFullPing) { + return record; + } + equal(record.syncs.length, 1); + return record.syncs[0]; +} + +// Perform a sync and validate all telemetry caused by the sync. If fnValidate +// is null, we just check the ping records success. If fnValidate is specified, +// then the sync must have recorded just a single sync, and that sync will be +// passed to the function to be checked. +async function sync_and_validate_telem( + fnValidate = null, + wantFullPing = false +) { + let numErrors = 0; + let telem = get_sync_test_telemetry(); + let oldSubmit = telem.submit; + try { + telem.submit = function (record) { + // This is called via an observer, so failures here don't cause the test + // to fail :( + try { + // All pings must be valid. + assert_valid_ping(record); + if (fnValidate) { + // for historical reasons most of these callbacks expect a "sync" + // record, not the entire ping. + if (wantFullPing) { + fnValidate(record); + } else { + Assert.equal(record.syncs.length, 1); + fnValidate(record.syncs[0]); + } + } else { + // no validation function means it must be a "success" ping. + assert_success_ping(record); + } + } catch (ex) { + print("Failure in ping validation callback", ex, "\n", ex.stack); + numErrors += 1; + } + }; + await Service.sync(); + Assert.equal(numErrors, 0, "There were telemetry validation errors"); + } finally { + telem.submit = oldSubmit; + } +} + +// Used for the (many) cases where we do a 'partial' sync, where only a single +// engine is actually synced, but we still want to ensure we're generating a +// valid ping. Returns a promise that resolves to the ping, or rejects with the +// thrown error after calling an optional callback. +async function sync_engine_and_validate_telem( + engine, + allowErrorPings, + onError, + wantFullPing = false +) { + let telem = get_sync_test_telemetry(); + let caughtError = null; + // Clear out status, so failures from previous syncs won't show up in the + // telemetry ping. + let { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" + ); + Status._engines = {}; + Status.partial = false; + // Ideally we'd clear these out like we do with engines, (probably via + // Status.resetSync()), but this causes *numerous* tests to fail, so we just + // assume that if no failureReason or engine failures are set, and the + // status properties are the same as they were initially, that it's just + // a leftover. + // This is only an issue since we're triggering the sync of just one engine, + // without doing any other parts of the sync. + let initialServiceStatus = Status._service; + let initialSyncStatus = Status._sync; + + let oldSubmit = telem.submit; + let submitPromise = new Promise((resolve, reject) => { + telem.submit = function (ping) { + telem.submit = oldSubmit; + ping.syncs.forEach(record => { + if (record && record.status) { + // did we see anything to lead us to believe that something bad actually happened + let realProblem = + record.failureReason || + record.engines.some(e => { + if (e.failureReason || e.status) { + return true; + } + if (e.outgoing && e.outgoing.some(o => o.failed > 0)) { + return true; + } + return e.incoming && e.incoming.failed; + }); + if (!realProblem) { + // no, so if the status is the same as it was initially, just assume + // that its leftover and that we can ignore it. + if (record.status.sync && record.status.sync == initialSyncStatus) { + delete record.status.sync; + } + if ( + record.status.service && + record.status.service == initialServiceStatus + ) { + delete record.status.service; + } + if (!record.status.sync && !record.status.service) { + delete record.status; + } + } + } + }); + if (allowErrorPings) { + assert_valid_ping(ping); + } else { + assert_success_ping(ping); + } + equal(ping.syncs.length, 1); + if (caughtError) { + if (onError) { + onError(ping.syncs[0], ping); + } + reject(caughtError); + } else if (wantFullPing) { + resolve(ping); + } else { + resolve(ping.syncs[0]); + } + }; + }); + // neuter the scheduler as it interacts badly with some of the tests - the + // engine being synced usually isn't the registered engine, so we see + // scored incremented and not removed, which schedules unexpected syncs. + let oldObserve = Service.scheduler.observe; + Service.scheduler.observe = () => {}; + try { + Svc.Obs.notify("weave:service:sync:start"); + try { + await engine.sync(); + } catch (e) { + caughtError = e; + } + if (caughtError) { + Svc.Obs.notify("weave:service:sync:error", caughtError); + } else { + Svc.Obs.notify("weave:service:sync:finish"); + } + } finally { + Service.scheduler.observe = oldObserve; + } + return submitPromise; +} + +// Returns a promise that resolves once the specified observer notification +// has fired. +function promiseOneObserver(topic) { + return new Promise(resolve => { + let observer = function (subject, data) { + Svc.Obs.remove(topic, observer); + resolve({ subject, data }); + }; + Svc.Obs.add(topic, observer); + }); +} + +async function registerRotaryEngine() { + let { RotaryEngine } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/rotaryengine.sys.mjs" + ); + await Service.engineManager.clear(); + + await Service.engineManager.register(RotaryEngine); + let engine = Service.engineManager.get("rotary"); + let syncID = await engine.resetLocalSyncID(); + engine.enabled = true; + + return { engine, syncID, tracker: engine._tracker }; +} + +// Set the validation prefs to attempt validation every time to avoid non-determinism. +function enableValidationPrefs(engines = ["bookmarks"]) { + for (let engine of engines) { + Svc.PrefBranch.setIntPref(`engine.${engine}.validation.interval`, 0); + Svc.PrefBranch.setIntPref( + `engine.${engine}.validation.percentageChance`, + 100 + ); + Svc.PrefBranch.setIntPref(`engine.${engine}.validation.maxRecords`, -1); + Svc.PrefBranch.setBoolPref(`engine.${engine}.validation.enabled`, true); + } +} + +async function serverForEnginesWithKeys(users, engines, callback) { + // Generate and store a fake default key bundle to avoid resetting the client + // before the first sync. + let wbo = await Service.collectionKeys.generateNewKeysWBO(); + let modified = new_timestamp(); + Service.collectionKeys.setContents(wbo.cleartext, modified); + + let allEngines = [Service.clientsEngine].concat(engines); + + let globalEngines = {}; + for (let engine of allEngines) { + let syncID = await engine.resetLocalSyncID(); + globalEngines[engine.name] = { version: engine.version, syncID }; + } + + let contents = { + meta: { + global: { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: globalEngines, + }, + }, + crypto: { + keys: encryptPayload(wbo.cleartext), + }, + }; + for (let engine of allEngines) { + contents[engine.name] = {}; + } + + return serverForUsers(users, contents, callback); +} + +async function serverForFoo(engine, callback) { + // The bookmarks engine *always* tracks changes, meaning we might try + // and sync due to the bookmarks we ourselves create! Worse, because we + // do an engine sync only, there's no locking - so we end up with multiple + // syncs running. Neuter that by making the threshold very large. + Service.scheduler.syncThreshold = 10000000; + return serverForEnginesWithKeys({ foo: "password" }, engine, callback); +} + +// Places notifies history observers asynchronously, so `addVisits` might return +// before the tracker receives the notification. This helper registers an +// observer that resolves once the expected notification fires. +async function promiseVisit(expectedType, expectedURI) { + return new Promise(resolve => { + function done(type, uri) { + if (uri == expectedURI.spec && type == expectedType) { + PlacesObservers.removeListener( + ["page-visited", "page-removed"], + observer.handlePlacesEvents + ); + resolve(); + } + } + let observer = { + handlePlacesEvents(events) { + Assert.equal(events.length, 1); + + if (events[0].type === "page-visited") { + done("added", events[0].url); + } else if (events[0].type === "page-removed") { + Assert.ok(events[0].isRemovedFromStore); + done("removed", events[0].url); + } + }, + }; + PlacesObservers.addListener( + ["page-visited", "page-removed"], + observer.handlePlacesEvents + ); + }); +} + +async function addVisit( + suffix, + referrer = null, + transition = PlacesUtils.history.TRANSITION_LINK +) { + let uriString = "http://getfirefox.com/" + suffix; + let uri = CommonUtils.makeURI(uriString); + _("Adding visit for URI " + uriString); + + let visitAddedPromise = promiseVisit("added", uri); + await PlacesTestUtils.addVisits({ + uri, + visitDate: Date.now() * 1000, + transition, + referrer, + }); + await visitAddedPromise; + + return uri; +} + +function bookmarkNodesToInfos(nodes) { + return nodes.map(node => { + let info = { + guid: node.guid, + index: node.index, + }; + if (node.children) { + info.children = bookmarkNodesToInfos(node.children); + } + return info; + }); +} + +async function assertBookmarksTreeMatches(rootGuid, expected, message) { + let root = await PlacesUtils.promiseBookmarksTree(rootGuid, { + includeItemIds: true, + }); + let actual = bookmarkNodesToInfos(root.children); + + if (!ObjectUtils.deepEqual(actual, expected)) { + _(`Expected structure for ${rootGuid}`, JSON.stringify(expected)); + _(`Actual structure for ${rootGuid}`, JSON.stringify(actual)); + throw new Assert.constructor.AssertionError({ actual, expected, message }); + } +} + +function add_bookmark_test(task) { + const { BookmarksEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" + ); + + add_task(async function () { + _(`Running bookmarks test ${task.name}`); + let engine = new BookmarksEngine(Service); + await engine.initialize(); + await engine._resetClient(); + try { + await task(engine); + } finally { + await engine.finalize(); + } + }); +} diff --git a/src/zen/tests/mochitests/sync/head_http_server.js b/src/zen/tests/mochitests/sync/head_http_server.js new file mode 100644 index 000000000..35ea77203 --- /dev/null +++ b/src/zen/tests/mochitests/sync/head_http_server.js @@ -0,0 +1,1268 @@ +/* import-globals-from head_appinfo.js */ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ +/* import-globals-from head_helpers.js */ + +var Cm = Components.manager; + +// Shared logging for all HTTP server functions. +var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs"); +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { + MockFxaStorageManager, + SyncTestingInfrastructure, + configureFxAccountIdentity, + configureIdentity, + encryptPayload, + getLoginTelemetryScalar, + makeFxAccountsInternalMock, + makeIdentityConfig, + promiseNamedTimer, + promiseZeroTimer, + sumHistogram, + syncTestLogging, + waitForZeroTimer, +} = ChromeUtils.importESModule( + "resource://testing-common/services/sync/utils.sys.mjs" +); + +const SYNC_HTTP_LOGGER = "Sync.Test.Server"; + +// While the sync code itself uses 1.5, the tests hard-code 1.1, +// so we're sticking with 1.1 here. +const SYNC_API_VERSION = "1.1"; + +// Use the same method that record.js does, which mirrors the server. +// The server returns timestamps with 1/100 sec granularity. Note that this is +// subject to change: see Bug 650435. +function new_timestamp() { + return round_timestamp(Date.now()); +} + +// Rounds a millisecond timestamp `t` to seconds, with centisecond precision. +function round_timestamp(t) { + return Math.round(t / 10) / 100; +} + +function return_timestamp(request, response, timestamp) { + if (!timestamp) { + timestamp = new_timestamp(); + } + let body = "" + timestamp; + response.setHeader("X-Weave-Timestamp", body); + response.setStatusLine(request.httpVersion, 200, "OK"); + writeBytesToOutputStream(response.bodyOutputStream, body); + return timestamp; +} + +function has_hawk_header(req) { + return ( + req.hasHeader("Authorization") && + req.getHeader("Authorization").startsWith("Hawk") + ); +} + +function basic_auth_header(user, password) { + return "Basic " + btoa(user + ":" + CommonUtils.encodeUTF8(password)); +} + +function basic_auth_matches(req, user, password) { + if (!req.hasHeader("Authorization")) { + return false; + } + + let expected = basic_auth_header(user, CommonUtils.encodeUTF8(password)); + return req.getHeader("Authorization") == expected; +} + +function httpd_basic_auth_handler(body, metadata, response) { + if (basic_auth_matches(metadata, "guest", "guest")) { + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } else { + body = "This path exists and is protected - failed"; + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } + writeBytesToOutputStream(response.bodyOutputStream, body); +} + +/* + * Represent a WBO on the server + */ +function ServerWBO(id, initialPayload, modified) { + if (!id) { + throw new Error("No ID for ServerWBO!"); + } + this.id = id; + if (!initialPayload) { + return; + } + + if (typeof initialPayload == "object") { + initialPayload = JSON.stringify(initialPayload); + } + this.payload = initialPayload; + this.modified = modified || new_timestamp(); + this.sortindex = 0; +} +ServerWBO.prototype = { + get data() { + return JSON.parse(this.payload); + }, + + get() { + return { id: this.id, modified: this.modified, payload: this.payload }; + }, + + put(input) { + input = JSON.parse(input); + this.payload = input.payload; + this.modified = new_timestamp(); + this.sortindex = input.sortindex || 0; + }, + + delete() { + delete this.payload; + delete this.modified; + delete this.sortindex; + }, + + // This handler sets `newModified` on the response body if the collection + // timestamp has changed. This allows wrapper handlers to extract information + // that otherwise would exist only in the body stream. + handler() { + let self = this; + + return function (request, response) { + var statusCode = 200; + var status = "OK"; + var body; + + switch (request.method) { + case "GET": + if (self.payload) { + body = JSON.stringify(self.get()); + } else { + statusCode = 404; + status = "Not Found"; + body = "Not Found"; + } + break; + + case "PUT": + self.put(readBytesFromInputStream(request.bodyInputStream)); + body = JSON.stringify(self.modified); + response.setHeader("Content-Type", "application/json"); + response.newModified = self.modified; + break; + + case "DELETE": { + self.delete(); + let ts = new_timestamp(); + body = JSON.stringify(ts); + response.setHeader("Content-Type", "application/json"); + response.newModified = ts; + break; + } + } + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setStatusLine(request.httpVersion, statusCode, status); + writeBytesToOutputStream(response.bodyOutputStream, body); + }; + }, + + /** + * Get the cleartext data stored in the payload. + * + * This isn't `get cleartext`, because `x.cleartext.blah = 3;` wouldn't work, + * which seems like a footgun. + */ + getCleartext() { + return JSON.parse(JSON.parse(this.payload).ciphertext); + }, + + /** + * Setter for getCleartext(), but lets you adjust the modified timestamp too. + * Returns this ServerWBO object. + */ + setCleartext(cleartext, modifiedTimestamp = this.modified) { + this.payload = JSON.stringify(encryptPayload(cleartext)); + this.modified = modifiedTimestamp; + return this; + }, +}; + +/** + * Represent a collection on the server. The '_wbos' attribute is a + * mapping of id -> ServerWBO objects. + * + * Note that if you want these records to be accessible individually, + * you need to register their handlers with the server separately, or use a + * containing HTTP server that will do so on your behalf. + * + * @param wbos + * An object mapping WBO IDs to ServerWBOs. + * @param acceptNew + * If true, POSTs to this collection URI will result in new WBOs being + * created and wired in on the fly. + * @param timestamp + * An optional timestamp value to initialize the modified time of the + * collection. This should be in the format returned by new_timestamp(). + * + * @return the new ServerCollection instance. + */ +function ServerCollection(wbos, acceptNew, timestamp) { + this._wbos = wbos || {}; + this.acceptNew = acceptNew || false; + + /* + * Track modified timestamp. + * We can't just use the timestamps of contained WBOs: an empty collection + * has a modified time. + */ + this.timestamp = timestamp || new_timestamp(); + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); +} +ServerCollection.prototype = { + /** + * Convenience accessor for our WBO keys. + * Excludes deleted items, of course. + * + * @param filter + * A predicate function (applied to the ID and WBO) which dictates + * whether to include the WBO's ID in the output. + * + * @return an array of IDs. + */ + keys: function keys(filter) { + let ids = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (wbo.payload && (!filter || filter(id, wbo))) { + ids.push(id); + } + } + return ids; + }, + + /** + * Convenience method to get an array of WBOs. + * Optionally provide a filter function. + * + * @param filter + * A predicate function, applied to the WBO, which dictates whether to + * include the WBO in the output. + * + * @return an array of ServerWBOs. + */ + wbos: function wbos(filter) { + let os = []; + for (let wbo of Object.values(this._wbos)) { + if (wbo.payload) { + os.push(wbo); + } + } + + if (filter) { + return os.filter(filter); + } + return os; + }, + + /** + * Convenience method to get an array of parsed ciphertexts. + * + * @return an array of the payloads of each stored WBO. + */ + payloads() { + return this.wbos().map(wbo => wbo.getCleartext()); + }, + + // Just for syntactic elegance. + wbo: function wbo(id) { + return this._wbos[id]; + }, + + payload: function payload(id) { + return this.wbo(id).payload; + }, + + cleartext(id) { + return this.wbo(id).getCleartext(); + }, + + /** + * Insert the provided WBO under its ID. + * + * @return the provided WBO. + */ + insertWBO: function insertWBO(wbo) { + this.timestamp = Math.max(this.timestamp, wbo.modified); + return (this._wbos[wbo.id] = wbo); + }, + + /** + * Update an existing WBO's cleartext using a callback function that modifies + * the record in place, or returns a new record. + */ + updateRecord(id, updateCallback, optTimestamp) { + let wbo = this.wbo(id); + if (!wbo) { + throw new Error("No record with provided ID"); + } + let curCleartext = wbo.getCleartext(); + // Allow update callback to either return a new cleartext, or modify in place. + let newCleartext = updateCallback(curCleartext) || curCleartext; + wbo.setCleartext(newCleartext, optTimestamp); + // It is already inserted, but we might need to update our timestamp based + // on it's `modified` value, if `optTimestamp` was provided. + return this.insertWBO(wbo); + }, + + /** + * Insert a record, which may either an object with a cleartext property, or + * the cleartext property itself. + */ + insertRecord(record, timestamp = Math.round(Date.now() / 10) / 100) { + if (typeof timestamp != "number") { + throw new TypeError("insertRecord: Timestamp is not a number."); + } + if (!record.id) { + throw new Error("Attempt to insert record with no id"); + } + // Allow providing either the cleartext directly, or the CryptoWrapper-like. + let cleartext = record.cleartext || record; + return this.insert(record.id, encryptPayload(cleartext), timestamp); + }, + + /** + * Insert the provided payload as part of a new ServerWBO with the provided + * ID. + * + * @param id + * The GUID for the WBO. + * @param payload + * The payload, as provided to the ServerWBO constructor. + * @param modified + * An optional modified time for the ServerWBO. + * + * @return the inserted WBO. + */ + insert: function insert(id, payload, modified) { + return this.insertWBO(new ServerWBO(id, payload, modified)); + }, + + /** + * Removes an object entirely from the collection. + * + * @param id + * (string) ID to remove. + */ + remove: function remove(id) { + delete this._wbos[id]; + }, + + _inResultSet(wbo, options) { + return ( + wbo.payload && + (!options.ids || options.ids.includes(wbo.id)) && + (!options.newer || wbo.modified > options.newer) && + (!options.older || wbo.modified < options.older) + ); + }, + + count(options) { + options = options || {}; + let c = 0; + for (let wbo of Object.values(this._wbos)) { + if (wbo.modified && this._inResultSet(wbo, options)) { + c++; + } + } + return c; + }, + + get(options, request) { + let data = []; + for (let wbo of Object.values(this._wbos)) { + if (wbo.modified && this._inResultSet(wbo, options)) { + data.push(wbo); + } + } + switch (options.sort) { + case "newest": + data.sort((a, b) => b.modified - a.modified); + break; + + case "oldest": + data.sort((a, b) => a.modified - b.modified); + break; + + case "index": + data.sort((a, b) => b.sortindex - a.sortindex); + break; + + default: + if (options.sort) { + this._log.error( + "Error: client requesting unknown sort order", + options.sort + ); + throw new Error("Unknown sort order"); + } + // If the client didn't request a sort order, shuffle the records + // to ensure that we don't accidentally depend on the default order. + TestUtils.shuffle(data); + } + if (options.full) { + data = data.map(wbo => wbo.get()); + let start = options.offset || 0; + if (options.limit) { + let numItemsPastOffset = data.length - start; + data = data.slice(start, start + options.limit); + // use options as a backchannel to set x-weave-next-offset + if (numItemsPastOffset > options.limit) { + options.nextOffset = start + options.limit; + } + } else if (start) { + data = data.slice(start); + } + + if (request && request.getHeader("accept") == "application/newlines") { + this._log.error( + "Error: client requesting application/newlines content" + ); + throw new Error( + "This server should not serve application/newlines content" + ); + } + + // Use options as a backchannel to report count. + options.recordCount = data.length; + } else { + data = data.map(wbo => wbo.id); + let start = options.offset || 0; + if (options.limit) { + data = data.slice(start, start + options.limit); + options.nextOffset = start + options.limit; + } else if (start) { + data = data.slice(start); + } + options.recordCount = data.length; + } + return JSON.stringify(data); + }, + + post(input) { + input = JSON.parse(input); + let success = []; + let failed = {}; + + // This will count records where we have an existing ServerWBO + // registered with us as successful and all other records as failed. + for (let key in input) { + let record = input[key]; + let wbo = this.wbo(record.id); + if (!wbo && this.acceptNew) { + this._log.debug( + "Creating WBO " + JSON.stringify(record.id) + " on the fly." + ); + wbo = new ServerWBO(record.id); + this.insertWBO(wbo); + } + if (wbo) { + wbo.payload = record.payload; + wbo.modified = new_timestamp(); + wbo.sortindex = record.sortindex || 0; + success.push(record.id); + } else { + failed[record.id] = "no wbo configured"; + } + } + return { modified: new_timestamp(), success, failed }; + }, + + delete(options) { + let deleted = []; + for (let wbo of Object.values(this._wbos)) { + if (this._inResultSet(wbo, options)) { + this._log.debug("Deleting " + JSON.stringify(wbo)); + deleted.push(wbo.id); + wbo.delete(); + } + } + return deleted; + }, + + // This handler sets `newModified` on the response body if the collection + // timestamp has changed. + handler() { + let self = this; + + return function (request, response) { + var statusCode = 200; + var status = "OK"; + var body; + + // Parse queryString + let options = {}; + for (let chunk of request.queryString.split("&")) { + if (!chunk) { + continue; + } + chunk = chunk.split("="); + if (chunk.length == 1) { + options[chunk[0]] = ""; + } else { + options[chunk[0]] = chunk[1]; + } + } + // The real servers return 400 if ids= is specified without a list of IDs. + if (options.hasOwnProperty("ids")) { + if (!options.ids) { + response.setStatusLine(request.httpVersion, "400", "Bad Request"); + body = "Bad Request"; + writeBytesToOutputStream(response.bodyOutputStream, body); + return; + } + options.ids = options.ids.split(","); + } + if (options.newer) { + options.newer = parseFloat(options.newer); + } + if (options.older) { + options.older = parseFloat(options.older); + } + if (options.limit) { + options.limit = parseInt(options.limit, 10); + } + if (options.offset) { + options.offset = parseInt(options.offset, 10); + } + + switch (request.method) { + case "GET": { + body = self.get(options, request); + // see http://moz-services-docs.readthedocs.io/en/latest/storage/apis-1.5.html + // for description of these headers. + let { recordCount: records, nextOffset } = options; + + self._log.info("Records: " + records + ", nextOffset: " + nextOffset); + if (records != null) { + response.setHeader("X-Weave-Records", "" + records); + } + if (nextOffset) { + response.setHeader("X-Weave-Next-Offset", "" + nextOffset); + } + response.setHeader("X-Last-Modified", "" + self.timestamp); + break; + } + + case "POST": { + let res = self.post( + readBytesFromInputStream(request.bodyInputStream), + request + ); + body = JSON.stringify(res); + response.newModified = res.modified; + break; + } + + case "DELETE": { + self._log.debug("Invoking ServerCollection.DELETE."); + let deleted = self.delete(options, request); + let ts = new_timestamp(); + body = JSON.stringify(ts); + response.newModified = ts; + response.deleted = deleted; + break; + } + } + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + + // Update the collection timestamp to the appropriate modified time. + // This is either a value set by the handler, or the current time. + if (request.method != "GET") { + self.timestamp = + response.newModified >= 0 ? response.newModified : new_timestamp(); + } + response.setHeader("X-Last-Modified", "" + self.timestamp, false); + + response.setStatusLine(request.httpVersion, statusCode, status); + writeBytesToOutputStream(response.bodyOutputStream, body); + }; + }, +}; + +/* + * Test setup helpers. + */ +function sync_httpd_setup(handlers) { + handlers["/1.1/foo/storage/meta/global"] = new ServerWBO( + "global", + {} + ).handler(); + return httpd_setup(handlers); +} + +/* + * Track collection modified times. Return closures. + * + * XXX - DO NOT USE IN NEW TESTS + * + * This code has very limited and very hacky timestamp support - the test + * server now has more complete and correct support - using this helper + * may cause strangeness wrt timestamp headers and 412 responses. + */ +function track_collections_helper() { + /* + * Our tracking object. + */ + let collections = {}; + + /* + * Update the timestamp of a collection. + */ + function update_collection(coll, ts) { + _("Updating collection " + coll + " to " + ts); + let timestamp = ts || new_timestamp(); + collections[coll] = timestamp; + } + + /* + * Invoke a handler, updating the collection's modified timestamp unless + * it's a GET request. + */ + function with_updated_collection(coll, f) { + return function (request, response) { + f.call(this, request, response); + + // Update the collection timestamp to the appropriate modified time. + // This is either a value set by the handler, or the current time. + if (request.method != "GET") { + update_collection(coll, response.newModified); + } + }; + } + + /* + * Return the info/collections object. + */ + function info_collections(request, response) { + let body = "Error."; + switch (request.method) { + case "GET": + body = JSON.stringify(collections); + break; + default: + throw new Error("Non-GET on info_collections."); + } + + response.setHeader("Content-Type", "application/json"); + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setStatusLine(request.httpVersion, 200, "OK"); + writeBytesToOutputStream(response.bodyOutputStream, body); + } + + return { + collections, + handler: info_collections, + with_updated_collection, + update_collection, + }; +} + +// ===========================================================================// +// httpd.js-based Sync server. // +// ===========================================================================// + +/** + * In general, the preferred way of using SyncServer is to directly introspect + * it. Callbacks are available for operations which are hard to verify through + * introspection, such as deletions. + * + * One of the goals of this server is to provide enough hooks for test code to + * find out what it needs without monkeypatching. Use this object as your + * prototype, and override as appropriate. + */ +var SyncServerCallback = { + onCollectionDeleted: function onCollectionDeleted() {}, + onItemDeleted: function onItemDeleted() {}, + + /** + * Called at the top of every request. + * + * Allows the test to inspect the request. Hooks should be careful not to + * modify or change state of the request or they may impact future processing. + * The response is also passed so the callback can set headers etc - but care + * must be taken to not screw with the response body or headers that may + * conflict with normal operation of this server. + */ + onRequest: function onRequest() {}, +}; + +/** + * Construct a new test Sync server. Takes a callback object (e.g., + * SyncServerCallback) as input. + */ +function SyncServer(callback) { + this.callback = callback || Object.create(SyncServerCallback); + this.server = new HttpServer(); + this.started = false; + this.users = {}; + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); + + // Install our own default handler. This allows us to mess around with the + // whole URL space. + let handler = this.server._handler; + handler._handleDefault = this.handleDefault.bind(this, handler); +} +SyncServer.prototype = { + server: null, // HttpServer. + users: null, // Map of username => {collections, password}. + + /** + * Start the SyncServer's underlying HTTP server. + * + * @param port + * The numeric port on which to start. -1 implies the default, a + * randomly chosen port. + * @param cb + * A callback function (of no arguments) which is invoked after + * startup. + */ + start: function start(port = -1, cb) { + if (this.started) { + this._log.warn("Warning: server already started on " + this.port); + return; + } + try { + this.server.start(port); + let i = this.server.identity; + this.port = i.primaryPort; + this.baseURI = + i.primaryScheme + "://" + i.primaryHost + ":" + i.primaryPort + "/"; + this.started = true; + if (cb) { + cb(); + } + } catch (ex) { + _("=========================================="); + _("Got exception starting Sync HTTP server."); + _("Error: " + Log.exceptionStr(ex)); + _("Is there a process already listening on port " + port + "?"); + _("=========================================="); + do_throw(ex); + } + }, + + /** + * Stop the SyncServer's HTTP server. + * + * @param cb + * A callback function. Invoked after the server has been stopped. + */ + stop: function stop(cb) { + if (!this.started) { + this._log.warn( + "SyncServer: Warning: server not running. Can't stop me now!" + ); + return; + } + + this.server.stop(cb); + this.started = false; + }, + + /** + * Return a server timestamp for a record. + * The server returns timestamps with 1/100 sec granularity. Note that this is + * subject to change: see Bug 650435. + */ + timestamp: function timestamp() { + return new_timestamp(); + }, + + /** + * Create a new user, complete with an empty set of collections. + * + * @param username + * The username to use. An Error will be thrown if a user by that name + * already exists. + * @param password + * A password string. + * + * @return a user object, as would be returned by server.user(username). + */ + registerUser: function registerUser(username, password) { + if (username in this.users) { + throw new Error("User already exists."); + } + this.users[username] = { + password, + collections: {}, + }; + return this.user(username); + }, + + userExists: function userExists(username) { + return username in this.users; + }, + + getCollection: function getCollection(username, collection) { + return this.users[username].collections[collection]; + }, + + _insertCollection: function _insertCollection(collections, collection, wbos) { + let coll = new ServerCollection(wbos, true); + coll.collectionHandler = coll.handler(); + collections[collection] = coll; + return coll; + }, + + createCollection: function createCollection(username, collection, wbos) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let collections = this.users[username].collections; + if (collection in collections) { + throw new Error("Collection already exists."); + } + return this._insertCollection(collections, collection, wbos); + }, + + /** + * Accept a map like the following: + * { + * meta: {global: {version: 1, ...}}, + * crypto: {"keys": {}, foo: {bar: 2}}, + * bookmarks: {} + * } + * to cause collections and WBOs to be created. + * If a collection already exists, no error is raised. + * If a WBO already exists, it will be updated to the new contents. + */ + createContents: function createContents(username, collections) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + for (let [id, contents] of Object.entries(collections)) { + let coll = + userCollections[id] || this._insertCollection(userCollections, id); + for (let [wboID, payload] of Object.entries(contents)) { + coll.insert(wboID, payload); + } + } + }, + + /** + * Insert a WBO in an existing collection. + */ + insertWBO: function insertWBO(username, collection, wbo) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + if (!(collection in userCollections)) { + throw new Error("Unknown collection."); + } + userCollections[collection].insertWBO(wbo); + return wbo; + }, + + /** + * Delete all of the collections for the named user. + * + * @param username + * The name of the affected user. + * + * @return a timestamp. + */ + deleteCollections: function deleteCollections(username) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + for (let name in userCollections) { + let coll = userCollections[name]; + this._log.trace("Bulk deleting " + name + " for " + username + "..."); + coll.delete({}); + } + this.users[username].collections = {}; + return this.timestamp(); + }, + + /** + * Simple accessor to allow collective binding and abbreviation of a bunch of + * methods. Yay! + * Use like this: + * + * let u = server.user("john"); + * u.collection("bookmarks").wbo("abcdefg").payload; // Etc. + * + * @return a proxy for the user data stored in this server. + */ + user: function user(username) { + let collection = this.getCollection.bind(this, username); + let createCollection = this.createCollection.bind(this, username); + let createContents = this.createContents.bind(this, username); + let modified = function (collectionName) { + return collection(collectionName).timestamp; + }; + let deleteCollections = this.deleteCollections.bind(this, username); + return { + collection, + createCollection, + createContents, + deleteCollections, + modified, + }; + }, + + /* + * Regular expressions for splitting up Sync request paths. + * Sync URLs are of the form: + * /$apipath/$version/$user/$further + * where $further is usually: + * storage/$collection/$wbo + * or + * storage/$collection + * or + * info/$op + * We assume for the sake of simplicity that $apipath is empty. + * + * N.B., we don't follow any kind of username spec here, because as far as I + * can tell there isn't one. See Bug 689671. Instead we follow the Python + * server code. + * + * Path: [all, version, username, first, rest] + * Storage: [all, collection?, id?] + */ + pathRE: + /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/, + storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/, + + defaultHeaders: {}, + + /** + * HTTP response utility. + */ + respond: function respond(req, resp, code, status, body, headers) { + resp.setStatusLine(req.httpVersion, code, status); + if (!headers) { + headers = this.defaultHeaders; + } + for (let header in headers) { + let value = headers[header]; + resp.setHeader(header, value); + } + resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false); + writeBytesToOutputStream(resp.bodyOutputStream, body); + }, + + /** + * This is invoked by the HttpServer. `this` is bound to the SyncServer; + * `handler` is the HttpServer's handler. + * + * TODO: need to use the correct Sync API response codes and errors here. + * TODO: Basic Auth. + * TODO: check username in path against username in BasicAuth. + */ + handleDefault: function handleDefault(handler, req, resp) { + try { + this._handleDefault(handler, req, resp); + } catch (e) { + if (e instanceof HttpError) { + this.respond(req, resp, e.code, e.description, "", {}); + } else { + throw e; + } + } + }, + + _handleDefault: function _handleDefault(handler, req, resp) { + this._log.debug( + "SyncServer: Handling request: " + req.method + " " + req.path + ); + + if (this.callback.onRequest) { + this.callback.onRequest(req, resp); + } + + let parts = this.pathRE.exec(req.path); + if (!parts) { + this._log.debug("SyncServer: Unexpected request: bad URL " + req.path); + throw HTTP_404; + } + + let [, version, username, first, rest] = parts; + // Doing a float compare of the version allows for us to pretend there was + // a node-reassignment - eg, we could re-assign from "1.1/user/" to + // "1.10/user" - this server will then still accept requests with the new + // URL while any code in sync itself which compares URLs will see a + // different URL. + if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) { + this._log.debug("SyncServer: Unknown version."); + throw HTTP_404; + } + + if (!this.userExists(username)) { + this._log.debug("SyncServer: Unknown user."); + throw HTTP_401; + } + + // Hand off to the appropriate handler for this path component. + if (first in this.toplevelHandlers) { + let newHandler = this.toplevelHandlers[first]; + return newHandler.call( + this, + newHandler, + req, + resp, + version, + username, + rest + ); + } + this._log.debug("SyncServer: Unknown top-level " + first); + throw HTTP_404; + }, + + /** + * Compute the object that is returned for an info/collections request. + */ + infoCollections: function infoCollections(username) { + let responseObject = {}; + let colls = this.users[username].collections; + for (let coll in colls) { + responseObject[coll] = colls[coll].timestamp; + } + this._log.trace( + "SyncServer: info/collections returning " + JSON.stringify(responseObject) + ); + return responseObject; + }, + + /** + * Collection of the handler methods we use for top-level path components. + */ + toplevelHandlers: { + storage: function handleStorage( + handler, + req, + resp, + version, + username, + rest + ) { + let respond = this.respond.bind(this, req, resp); + if (!rest || !rest.length) { + this._log.debug( + "SyncServer: top-level storage " + req.method + " request." + ); + + // TODO: verify if this is spec-compliant. + if (req.method != "DELETE") { + respond(405, "Method Not Allowed", "[]", { Allow: "DELETE" }); + return undefined; + } + + // Delete all collections and track the timestamp for the response. + let timestamp = this.user(username).deleteCollections(); + + // Return timestamp and OK for deletion. + respond(200, "OK", JSON.stringify(timestamp)); + return undefined; + } + + let match = this.storageRE.exec(rest); + if (!match) { + this._log.warn("SyncServer: Unknown storage operation " + rest); + throw HTTP_404; + } + let [, collection, wboID] = match; + let coll = this.getCollection(username, collection); + + let checkXIUSFailure = () => { + if (req.hasHeader("x-if-unmodified-since")) { + let xius = parseFloat(req.getHeader("x-if-unmodified-since")); + // Sadly the way our tests are setup, we often end up with xius of + // zero (typically when syncing just one engine, so the date from + // info/collections isn't used) - so we allow that to work. + // Further, the Python server treats non-existing collections as + // having a timestamp of 0. + let collTimestamp = coll ? coll.timestamp : 0; + if (xius && xius < collTimestamp) { + this._log.info( + `x-if-unmodified-since mismatch - request wants ${xius} but our collection has ${collTimestamp}` + ); + respond(412, "precondition failed", "precondition failed"); + return true; + } + } + return false; + }; + + switch (req.method) { + case "GET": { + if (!coll) { + if (wboID) { + respond(404, "Not found", "Not found"); + return undefined; + } + // *cries inside*: - apparently the real sync server returned 200 + // here for some time, then returned 404 for some time (bug 687299), + // and now is back to 200 (bug 963332). + respond(200, "OK", "[]"); + return undefined; + } + if (!wboID) { + return coll.collectionHandler(req, resp); + } + let wbo = coll.wbo(wboID); + if (!wbo) { + respond(404, "Not found", "Not found"); + return undefined; + } + return wbo.handler()(req, resp); + } + case "DELETE": { + if (!coll) { + respond(200, "OK", "{}"); + return undefined; + } + if (checkXIUSFailure()) { + return undefined; + } + if (wboID) { + let wbo = coll.wbo(wboID); + if (wbo) { + wbo.delete(); + this.callback.onItemDeleted(username, collection, wboID); + } + respond(200, "OK", "{}"); + return undefined; + } + coll.collectionHandler(req, resp); + + // Spot if this is a DELETE for some IDs, and don't blow away the + // whole collection! + // + // We already handled deleting the WBOs by invoking the deleted + // collection's handler. However, in the case of + // + // DELETE storage/foobar + // + // we also need to remove foobar from the collections map. This + // clause tries to differentiate the above request from + // + // DELETE storage/foobar?ids=foo,baz + // + // and do the right thing. + // TODO: less hacky method. + if (-1 == req.queryString.indexOf("ids=")) { + // When you delete the entire collection, we drop it. + this._log.debug("Deleting entire collection."); + delete this.users[username].collections[collection]; + this.callback.onCollectionDeleted(username, collection); + } + + // Notify of item deletion. + let deleted = resp.deleted || []; + for (let i = 0; i < deleted.length; ++i) { + this.callback.onItemDeleted(username, collection, deleted[i]); + } + return undefined; + } + case "PUT": + // PUT and POST have slightly different XIUS semantics - for PUT, + // the check is against the item, whereas for POST it is against + // the collection. So first, a special-case for PUT. + if (req.hasHeader("x-if-unmodified-since")) { + let xius = parseFloat(req.getHeader("x-if-unmodified-since")); + // treat and xius of zero as if it wasn't specified - this happens + // in some of our tests for a new collection. + if (xius > 0) { + let wbo = coll.wbo(wboID); + if (xius < wbo.modified) { + this._log.info( + `x-if-unmodified-since mismatch - request wants ${xius} but wbo has ${wbo.modified}` + ); + respond(412, "precondition failed", "precondition failed"); + return undefined; + } + wbo.handler()(req, resp); + coll.timestamp = resp.newModified; + return resp; + } + } + // fall through to post. + case "POST": + if (checkXIUSFailure()) { + return undefined; + } + if (!coll) { + coll = this.createCollection(username, collection); + } + + if (wboID) { + let wbo = coll.wbo(wboID); + if (!wbo) { + this._log.trace( + "SyncServer: creating WBO " + collection + "/" + wboID + ); + wbo = coll.insert(wboID); + } + // Rather than instantiate each WBO's handler function, do it once + // per request. They get hit far less often than do collections. + wbo.handler()(req, resp); + coll.timestamp = resp.newModified; + return resp; + } + return coll.collectionHandler(req, resp); + default: + throw new Error("Request method " + req.method + " not implemented."); + } + }, + + info: function handleInfo(handler, req, resp, version, username, rest) { + switch (rest) { + case "collections": { + let body = JSON.stringify(this.infoCollections(username)); + this.respond(req, resp, 200, "OK", body, { + "Content-Type": "application/json", + }); + return; + } + case "collection_usage": + case "collection_counts": + case "quota": + // TODO: implement additional info methods. + this.respond(req, resp, 200, "OK", "TODO"); + return; + default: + // TODO + this._log.warn("SyncServer: Unknown info operation " + rest); + throw HTTP_404; + } + }, + }, +}; + +/** + * Test helper. + */ +function serverForUsers(users, contents, callback) { + let server = new SyncServer(callback); + for (let [user, pass] of Object.entries(users)) { + server.registerUser(user, pass); + server.createContents(user, contents); + } + server.start(); + return server; +} diff --git a/src/zen/tests/mochitests/sync/missing-sourceuri.json b/src/zen/tests/mochitests/sync/missing-sourceuri.json new file mode 100644 index 000000000..dcd487726 --- /dev/null +++ b/src/zen/tests/mochitests/sync/missing-sourceuri.json @@ -0,0 +1,20 @@ +{ + "next": null, + "results": [ + { + "name": "Restartless Test Extension", + "type": "extension", + "guid": "missing-sourceuri@tests.mozilla.org", + "current_version": { + "version": "1.0", + "files": [ + { + "platform": "all", + "size": 485 + } + ] + }, + "last_updated": "2011-09-05T20:42:09Z" + } + ] +} diff --git a/src/zen/tests/mochitests/sync/missing-xpi-search.json b/src/zen/tests/mochitests/sync/missing-xpi-search.json new file mode 100644 index 000000000..55f6432b2 --- /dev/null +++ b/src/zen/tests/mochitests/sync/missing-xpi-search.json @@ -0,0 +1,21 @@ +{ + "next": null, + "results": [ + { + "name": "Non-Restartless Test Extension", + "type": "extension", + "guid": "missing-xpi@tests.mozilla.org", + "current_version": { + "version": "1.0", + "files": [ + { + "platform": "all", + "size": 485, + "url": "http://127.0.0.1:8888/THIS_DOES_NOT_EXIST.xpi" + } + ] + }, + "last_updated": "2011-09-05T20:42:09Z" + } + ] +} diff --git a/src/zen/tests/mochitests/sync/prefs_test_prefs_store.js b/src/zen/tests/mochitests/sync/prefs_test_prefs_store.js new file mode 100644 index 000000000..63851a693 --- /dev/null +++ b/src/zen/tests/mochitests/sync/prefs_test_prefs_store.js @@ -0,0 +1,47 @@ +// This is a "preferences" file used by test_prefs_store.js + +/* global pref, user_pref */ + +// The prefs that control what should be synced. +// Most of these are "default" prefs, so the value itself will not sync. +pref("services.sync.prefs.sync.testing.int", true); +pref("services.sync.prefs.sync.testing.string", true); +pref("services.sync.prefs.sync.testing.bool", true); +pref("services.sync.prefs.sync.testing.dont.change", true); +// This is a default pref, but has the special "sync-seen" pref. +pref("services.sync.prefs.sync.testing.seen", true); +pref("services.sync.prefs.sync-seen.testing.seen", false); + +// this one is a user pref, so it *will* sync. +user_pref("services.sync.prefs.sync.testing.turned.off", false); +pref("services.sync.prefs.sync.testing.nonexistent", true); +pref("services.sync.prefs.sync.testing.default", true); +pref("services.sync.prefs.sync.testing.synced.url", true); +// We shouldn't sync the URL, or the flag that says we should sync the pref +// (otherwise some other client might overwrite our local value). +user_pref("services.sync.prefs.sync.testing.unsynced.url", true); + +// The preference values - these are all user_prefs, otherwise their value +// will not be synced. +user_pref("testing.int", 123); +user_pref("testing.string", "ohai"); +user_pref("testing.bool", true); +user_pref("testing.dont.change", "Please don't change me."); +user_pref("testing.turned.off", "I won't get synced."); +user_pref("testing.not.turned.on", "I won't get synced either!"); +// Some url we don't want to sync +user_pref( + "testing.unsynced.url", + "moz-extension://d5d31b00-b944-4afb-bd3d-d0326551a0ae" +); +user_pref("testing.synced.url", "https://www.example.com"); + +// A pref that exists but still has the default value - will be synced with +// null as the value. +pref("testing.default", "I'm the default value"); + +// A pref that has the default value - it will start syncing as soon as +// we see a change, even if the change is to the default. +pref("testing.seen", "the value"); + +// A pref that shouldn't be synced diff --git a/src/zen/tests/mochitests/sync/rewrite-search.json b/src/zen/tests/mochitests/sync/rewrite-search.json new file mode 100644 index 000000000..740b6f2c3 --- /dev/null +++ b/src/zen/tests/mochitests/sync/rewrite-search.json @@ -0,0 +1,21 @@ +{ + "next": null, + "results": [ + { + "name": "Rewrite Test Extension", + "type": "extension", + "guid": "rewrite@tests.mozilla.org", + "current_version": { + "version": "1.0", + "files": [ + { + "platform": "all", + "size": 485, + "url": "http://127.0.0.1:8888/require.xpi?src=api" + } + ] + }, + "last_updated": "2011-09-05T20:42:09Z" + } + ] +} diff --git a/src/zen/tests/mochitests/sync/systemaddon-search.json b/src/zen/tests/mochitests/sync/systemaddon-search.json new file mode 100644 index 000000000..a81271491 --- /dev/null +++ b/src/zen/tests/mochitests/sync/systemaddon-search.json @@ -0,0 +1,21 @@ +{ + "next": null, + "results": [ + { + "name": "System Add-on Test", + "type": "extension", + "guid": "system1@tests.mozilla.org", + "current_version": { + "version": "1.0", + "files": [ + { + "platform": "all", + "size": 999, + "url": "http://127.0.0.1:8888/system.xpi" + } + ] + }, + "last_updated": "2011-09-05T20:42:09Z" + } + ] +} diff --git a/src/zen/tests/mochitests/sync/test_412.js b/src/zen/tests/mochitests/sync/test_412.js new file mode 100644 index 000000000..de0a2c087 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_412.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { RotaryEngine } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/rotaryengine.sys.mjs" +); + +add_task(async function test_412_not_treated_as_failure() { + await Service.engineManager.register(RotaryEngine); + let engine = Service.engineManager.get("rotary"); + + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + // add an item to the server to the first sync advances lastModified. + let collection = server.getCollection("foo", "rotary"); + let payload = encryptPayload({ + id: "existing", + something: "existing record", + }); + collection.insert("existing", payload); + + let promiseObserved = promiseOneObserver("weave:engine:sync:finish"); + try { + // Do sync. + _("initial sync to initialize the world"); + await Service.sync(); + + // create a new record that should be uploaded and arrange for our lastSync + // timestamp to be wrong so we get a 412. + engine._store.items = { new: "new record" }; + await engine._tracker.addChangedID("new", 0); + + let saw412 = false; + let _uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async () => { + let lastSync = await engine.getLastSync(); + await engine.setLastSync(lastSync - 2); + try { + await _uploadOutgoing.call(engine); + } catch (ex) { + saw412 = ex.status == 412; + throw ex; + } + }; + _("Second sync - expecting a 412"); + await Service.sync(); + await promiseObserved; + ok(saw412, "did see a 412 error"); + // But service status should be OK as the 412 shouldn't be treated as an error. + equal(Service.status.service, STATUS_OK); + } finally { + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_addon_utils.js b/src/zen/tests/mochitests/sync/test_addon_utils.js new file mode 100644 index 000000000..e9e49fb9e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_addon_utils.js @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonUtils } = ChromeUtils.importESModule( + "resource://services-sync/addonutils.sys.mjs" +); + +const HTTP_PORT = 8888; +const SERVER_ADDRESS = "http://127.0.0.1:8888"; + +Services.prefs.setStringPref( + "extensions.getAddons.get.url", + SERVER_ADDRESS + "/search/guid:%IDS%" +); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +function createAndStartHTTPServer(port = HTTP_PORT) { + try { + let server = new HttpServer(); + + server.registerFile( + "/search/guid:missing-sourceuri%40tests.mozilla.org", + do_get_file("missing-sourceuri.json") + ); + + server.registerFile( + "/search/guid:rewrite%40tests.mozilla.org", + do_get_file("rewrite-search.json") + ); + + server.start(port); + + return server; + } catch (ex) { + _("Got exception starting HTTP server on port " + port); + _("Error: " + Log.exceptionStr(ex)); + do_throw(ex); + } + return null; /* not hit, but keeps eslint happy! */ +} + +function run_test() { + syncTestLogging(); + + run_next_test(); +} + +add_task(async function test_handle_empty_source_uri() { + _("Ensure that search results without a sourceURI are properly ignored."); + + let server = createAndStartHTTPServer(); + + const ID = "missing-sourceuri@tests.mozilla.org"; + + const result = await AddonUtils.installAddons([ + { id: ID, requireSecureURI: false }, + ]); + + Assert.ok("installedIDs" in result); + Assert.equal(0, result.installedIDs.length); + + Assert.ok("skipped" in result); + Assert.ok(result.skipped.includes(ID)); + + await promiseStopServer(server); +}); + +add_test(function test_ignore_untrusted_source_uris() { + _("Ensures that source URIs from insecure schemes are rejected."); + + const bad = [ + "http://example.com/foo.xpi", + "ftp://example.com/foo.xpi", + "silly://example.com/foo.xpi", + ]; + + const good = ["https://example.com/foo.xpi"]; + + for (let s of bad) { + let sourceURI = Services.io.newURI(s); + let addon = { sourceURI, name: "bad", id: "bad" }; + + let canInstall = AddonUtils.canInstallAddon(addon); + Assert.ok(!canInstall, "Correctly rejected a bad URL"); + } + + for (let s of good) { + let sourceURI = Services.io.newURI(s); + let addon = { sourceURI, name: "good", id: "good" }; + + let canInstall = AddonUtils.canInstallAddon(addon); + Assert.ok(canInstall, "Correctly accepted a good URL"); + } + run_next_test(); +}); + +add_task(async function test_source_uri_rewrite() { + _("Ensure that a 'src=api' query string is rewritten to 'src=sync'"); + + // This tests for conformance with bug 708134 so server-side metrics aren't + // skewed. + + // We resort to monkeypatching because of the API design. + let oldFunction = + Object.getPrototypeOf(AddonUtils).installAddonFromSearchResult; + + let installCalled = false; + Object.getPrototypeOf(AddonUtils).installAddonFromSearchResult = + async function testInstallAddon(addon) { + Assert.equal( + SERVER_ADDRESS + "/require.xpi?src=sync", + addon.sourceURI.spec + ); + + installCalled = true; + + const install = await AddonUtils.getInstallFromSearchResult(addon); + Assert.equal( + SERVER_ADDRESS + "/require.xpi?src=sync", + install.sourceURI.spec + ); + Assert.deepEqual( + install.installTelemetryInfo, + { source: "sync" }, + "Got the expected installTelemetryInfo" + ); + + return { id: addon.id, addon, install }; + }; + + let server = createAndStartHTTPServer(); + + let installOptions = { + id: "rewrite@tests.mozilla.org", + requireSecureURI: false, + }; + await AddonUtils.installAddons([installOptions]); + + Assert.ok(installCalled); + Object.getPrototypeOf(AddonUtils).installAddonFromSearchResult = oldFunction; + + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_addons_engine.js b/src/zen/tests/mochitests/sync/test_addons_engine.js new file mode 100644 index 000000000..8c7b4ecc3 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_addons_engine.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { CHANGE_INSTALLED } = ChromeUtils.importESModule( + "resource://services-sync/addonsreconciler.sys.mjs" +); +const { AddonsEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/addons.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +Services.prefs.setStringPref( + "extensions.getAddons.get.url", + "http://localhost:8888/search/guid:%IDS%" +); +Services.prefs.setBoolPref("extensions.install.requireSecureOrigin", false); + +let engine; +let syncID; +let reconciler; +let tracker; + +AddonTestUtils.init(this); + +const ADDON_ID = "addon1@tests.mozilla.org"; +const XPI = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + name: "Test 1", + description: "Test Description", + browser_specific_settings: { gecko: { id: ADDON_ID } }, + }, +}); + +async function resetReconciler() { + reconciler._addons = {}; + reconciler._changes = []; + + await reconciler.saveState(); + + await tracker.clearChangedIDs(); +} + +add_task(async function setup() { + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" + ); + AddonTestUtils.overrideCertDB(); + await AddonTestUtils.promiseStartupManager(); + + await Service.engineManager.register(AddonsEngine); + engine = Service.engineManager.get("addons"); + syncID = await engine.resetLocalSyncID(); + reconciler = engine._reconciler; + tracker = engine._tracker; + + reconciler.startListening(); + + // Don't flush to disk in the middle of an event listener! + // This causes test hangs on WinXP. + reconciler._shouldPersist = false; + + await resetReconciler(); +}); + +// This is a basic sanity test for the unit test itself. If this breaks, the +// add-ons API likely changed upstream. +add_task(async function test_addon_install() { + _("Ensure basic add-on APIs work as expected."); + + let install = await AddonManager.getInstallForFile(XPI); + Assert.notEqual(install, null); + Assert.equal(install.type, "extension"); + Assert.equal(install.name, "Test 1"); + + await resetReconciler(); +}); + +add_task(async function test_find_dupe() { + _("Ensure the _findDupe() implementation is sane."); + + // This gets invoked at the top of sync, which is bypassed by this + // test, so we do it manually. + await engine._refreshReconcilerState(); + + let addon = await installAddon(XPI, reconciler); + + let record = { + id: Utils.makeGUID(), + addonID: ADDON_ID, + enabled: true, + applicationID: Services.appinfo.ID, + source: "amo", + }; + + let dupe = await engine._findDupe(record); + Assert.equal(addon.syncGUID, dupe); + + record.id = addon.syncGUID; + dupe = await engine._findDupe(record); + Assert.equal(null, dupe); + + await uninstallAddon(addon, reconciler); + await resetReconciler(); +}); + +add_task(async function test_get_changed_ids() { + let timerPrecision = Services.prefs.getBoolPref( + "privacy.reduceTimerPrecision" + ); + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", timerPrecision); + }); + + _("Ensure getChangedIDs() has the appropriate behavior."); + + _("Ensure getChangedIDs() returns an empty object by default."); + let changes = await engine.getChangedIDs(); + Assert.equal("object", typeof changes); + Assert.equal(0, Object.keys(changes).length); + + _("Ensure tracker changes are populated."); + let now = new Date(); + let changeTime = now.getTime() / 1000; + let guid1 = Utils.makeGUID(); + await tracker.addChangedID(guid1, changeTime); + + changes = await engine.getChangedIDs(); + Assert.equal("object", typeof changes); + Assert.equal(1, Object.keys(changes).length); + Assert.ok(guid1 in changes); + Assert.equal(changeTime, changes[guid1]); + + await tracker.clearChangedIDs(); + + _("Ensure reconciler changes are populated."); + let addon = await installAddon(XPI, reconciler); + await tracker.clearChangedIDs(); // Just in case. + changes = await engine.getChangedIDs(); + Assert.equal("object", typeof changes); + Assert.equal(1, Object.keys(changes).length); + Assert.ok(addon.syncGUID in changes); + _( + "Change time: " + changeTime + ", addon change: " + changes[addon.syncGUID] + ); + Assert.greaterOrEqual(changes[addon.syncGUID], changeTime); + + let oldTime = changes[addon.syncGUID]; + let guid2 = addon.syncGUID; + await uninstallAddon(addon, reconciler); + changes = await engine.getChangedIDs(); + Assert.equal(1, Object.keys(changes).length); + Assert.ok(guid2 in changes); + Assert.greater(changes[guid2], oldTime); + + _("Ensure non-syncable add-ons aren't picked up by reconciler changes."); + reconciler._addons = {}; + reconciler._changes = []; + let record = { + id: "DUMMY", + guid: Utils.makeGUID(), + enabled: true, + installed: true, + modified: new Date(), + type: "UNSUPPORTED", + scope: 0, + foreignInstall: false, + }; + reconciler.addons.DUMMY = record; + await reconciler._addChange(record.modified, CHANGE_INSTALLED, record); + + changes = await engine.getChangedIDs(); + _(JSON.stringify(changes)); + Assert.equal(0, Object.keys(changes).length); + + await resetReconciler(); +}); + +add_task(async function test_disabled_install_semantics() { + _("Ensure that syncing a disabled add-on preserves proper state."); + + // This is essentially a test for bug 712542, which snuck into the original + // add-on sync drop. It ensures that when an add-on is installed that the + // disabled state and incoming syncGUID is preserved, even on the next sync. + const USER = "foo"; + const PASSWORD = "password"; + + let server = new SyncServer(); + server.start(); + await SyncTestingInfrastructure(server, USER, PASSWORD); + + await generateNewKeys(Service.collectionKeys); + + let contents = { + meta: { + global: { engines: { addons: { version: engine.version, syncID } } }, + }, + crypto: {}, + addons: {}, + }; + + server.registerUser(USER, "password"); + server.createContents(USER, contents); + + let amoServer = new HttpServer(); + amoServer.registerFile( + "/search/guid:addon1%40tests.mozilla.org", + do_get_file("addon1-search.json") + ); + + amoServer.registerFile("/addon1.xpi", XPI); + amoServer.start(8888); + + // Insert an existing record into the server. + let id = Utils.makeGUID(); + let now = Date.now() / 1000; + + let record = encryptPayload({ + id, + applicationID: Services.appinfo.ID, + addonID: ADDON_ID, + enabled: false, + deleted: false, + source: "amo", + }); + let wbo = new ServerWBO(id, record, now - 2); + server.insertWBO(USER, "addons", wbo); + + _("Performing sync of add-ons engine."); + await engine._sync(); + + // At this point the non-restartless extension should be staged for install. + + // Don't need this server any more. + await promiseStopServer(amoServer); + + // We ensure the reconciler has recorded the proper ID and enabled state. + let addon = reconciler.getAddonStateFromSyncGUID(id); + Assert.notEqual(null, addon); + Assert.equal(false, addon.enabled); + + // We fake an app restart and perform another sync, just to make sure things + // are sane. + await AddonTestUtils.promiseRestartManager(); + + let collection = server.getCollection(USER, "addons"); + engine.lastModified = collection.timestamp; + await engine._sync(); + + // The client should not upload a new record. The old record should be + // retained and unmodified. + Assert.equal(1, collection.count()); + + let payload = collection.payloads()[0]; + Assert.notEqual(null, collection.wbo(id)); + Assert.equal(ADDON_ID, payload.addonID); + Assert.ok(!payload.enabled); + + await promiseStopServer(server); +}); + +add_test(function cleanup() { + // There's an xpcom-shutdown hook for this, but let's give this a shot. + reconciler.stopListening(); + run_next_test(); +}); diff --git a/src/zen/tests/mochitests/sync/test_addons_reconciler.js b/src/zen/tests/mochitests/sync/test_addons_reconciler.js new file mode 100644 index 000000000..c72b18b00 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_addons_reconciler.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonsReconciler, CHANGE_INSTALLED, CHANGE_UNINSTALLED } = + ChromeUtils.importESModule( + "resource://services-sync/addonsreconciler.sys.mjs" + ); +const { AddonsEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/addons.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" +); +AddonTestUtils.overrideCertDB(); + +const ADDON_ID = "addon1@tests.mozilla.org"; +const XPI = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + name: "Test 1", + description: "Test Description", + browser_specific_settings: { gecko: { id: ADDON_ID } }, + }, +}); + +function makeAddonsReconciler() { + const log = Service.engineManager.get("addons")._log; + const queueCaller = Async.asyncQueueCaller(log); + return new AddonsReconciler(queueCaller); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + Svc.PrefBranch.setBoolPref("engine.addons", true); + await Service.engineManager.register(AddonsEngine); +}); + +add_task(async function test_defaults() { + _("Ensure new objects have reasonable defaults."); + + let reconciler = makeAddonsReconciler(); + await reconciler.ensureStateLoaded(); + + Assert.ok(!reconciler._listening); + Assert.equal("object", typeof reconciler.addons); + Assert.equal(0, Object.keys(reconciler.addons).length); + Assert.equal(0, reconciler._changes.length); + Assert.equal(0, reconciler._listeners.length); +}); + +add_task(async function test_load_state_empty_file() { + _("Ensure loading from a missing file results in defaults being set."); + + let reconciler = makeAddonsReconciler(); + await reconciler.ensureStateLoaded(); + + let loaded = await reconciler.loadState(); + Assert.ok(!loaded); + + Assert.equal("object", typeof reconciler.addons); + Assert.equal(0, Object.keys(reconciler.addons).length); + Assert.equal(0, reconciler._changes.length); +}); + +add_task(async function test_install_detection() { + _("Ensure that add-on installation results in appropriate side-effects."); + + let reconciler = makeAddonsReconciler(); + await reconciler.ensureStateLoaded(); + reconciler.startListening(); + + let before = new Date(); + let addon = await installAddon(XPI); + let after = new Date(); + + Assert.equal(1, Object.keys(reconciler.addons).length); + Assert.ok(addon.id in reconciler.addons); + let record = reconciler.addons[ADDON_ID]; + + const KEYS = [ + "id", + "guid", + "enabled", + "installed", + "modified", + "type", + "scope", + "foreignInstall", + ]; + for (let key of KEYS) { + Assert.ok(key in record); + Assert.notEqual(null, record[key]); + } + + Assert.equal(addon.id, record.id); + Assert.equal(addon.syncGUID, record.guid); + Assert.ok(record.enabled); + Assert.ok(record.installed); + Assert.ok(record.modified >= before && record.modified <= after); + Assert.equal("extension", record.type); + Assert.ok(!record.foreignInstall); + + Assert.equal(1, reconciler._changes.length); + let change = reconciler._changes[0]; + Assert.ok(change[0] >= before && change[1] <= after); + Assert.equal(CHANGE_INSTALLED, change[1]); + Assert.equal(addon.id, change[2]); + + await uninstallAddon(addon); +}); + +add_task(async function test_uninstall_detection() { + _("Ensure that add-on uninstallation results in appropriate side-effects."); + + let reconciler = makeAddonsReconciler(); + await reconciler.ensureStateLoaded(); + reconciler.startListening(); + + reconciler._addons = {}; + reconciler._changes = []; + + let addon = await installAddon(XPI); + let id = addon.id; + + reconciler._changes = []; + await uninstallAddon(addon, reconciler); + + Assert.equal(1, Object.keys(reconciler.addons).length); + Assert.ok(id in reconciler.addons); + + let record = reconciler.addons[id]; + Assert.ok(!record.installed); + + Assert.equal(1, reconciler._changes.length); + let change = reconciler._changes[0]; + Assert.equal(CHANGE_UNINSTALLED, change[1]); + Assert.equal(id, change[2]); +}); + +add_task(async function test_load_state_future_version() { + _("Ensure loading a file from a future version results in no data loaded."); + + const FILENAME = "TEST_LOAD_STATE_FUTURE_VERSION"; + + let reconciler = makeAddonsReconciler(); + await reconciler.ensureStateLoaded(); + + // First we populate our new file. + let state = { version: 100, addons: { foo: {} }, changes: [[1, 1, "foo"]] }; + + // jsonSave() expects an object with ._log, so we give it a reconciler + // instance. + await Utils.jsonSave(FILENAME, reconciler, state); + + let loaded = await reconciler.loadState(FILENAME); + Assert.ok(!loaded); + + Assert.equal("object", typeof reconciler.addons); + Assert.equal(0, Object.keys(reconciler.addons).length); + Assert.equal(0, reconciler._changes.length); +}); + +add_task(async function test_prune_changes_before_date() { + _("Ensure that old changes are pruned properly."); + + let reconciler = makeAddonsReconciler(); + await reconciler.ensureStateLoaded(); + reconciler._changes = []; + + let now = new Date(); + const HOUR_MS = 1000 * 60 * 60; + + _("Ensure pruning an empty changes array works."); + reconciler.pruneChangesBeforeDate(now); + Assert.equal(0, reconciler._changes.length); + + let old = new Date(now.getTime() - HOUR_MS); + let young = new Date(now.getTime() - 1000); + reconciler._changes.push([old, CHANGE_INSTALLED, "foo"]); + reconciler._changes.push([young, CHANGE_INSTALLED, "bar"]); + Assert.equal(2, reconciler._changes.length); + + _("Ensure pruning with an old time won't delete anything."); + let threshold = new Date(old.getTime() - 1); + reconciler.pruneChangesBeforeDate(threshold); + Assert.equal(2, reconciler._changes.length); + + _("Ensure pruning a single item works."); + threshold = new Date(young.getTime() - 1000); + reconciler.pruneChangesBeforeDate(threshold); + Assert.equal(1, reconciler._changes.length); + Assert.notEqual(undefined, reconciler._changes[0]); + Assert.equal(young, reconciler._changes[0][0]); + Assert.equal("bar", reconciler._changes[0][2]); + + _("Ensure pruning all changes works."); + reconciler._changes.push([old, CHANGE_INSTALLED, "foo"]); + reconciler.pruneChangesBeforeDate(now); + Assert.equal(0, reconciler._changes.length); +}); diff --git a/src/zen/tests/mochitests/sync/test_addons_store.js b/src/zen/tests/mochitests/sync/test_addons_store.js new file mode 100644 index 000000000..660b13e20 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_addons_store.js @@ -0,0 +1,933 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonsEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/addons.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { SyncedRecordsTelemetry } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" +); + +const HTTP_PORT = 8888; + +Services.prefs.setStringPref( + "extensions.getAddons.get.url", + "http://localhost:8888/search/guid:%IDS%" +); +// Note that all compat-override URLs currently 404, but that's OK - the main +// thing is to avoid us hitting the real AMO. +Services.prefs.setStringPref( + "extensions.getAddons.compatOverides.url", + "http://localhost:8888/compat-override/guid:%IDS%" +); +Services.prefs.setBoolPref("extensions.install.requireSecureOrigin", false); +Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" +); +AddonTestUtils.overrideCertDB(); + +Services.prefs.setBoolPref("extensions.experiments.enabled", true); + +const SYSTEM_ADDON_ID = "system1@tests.mozilla.org"; +const THEME_ID = "synctheme@tests.mozilla.org"; + +add_setup(async function setupBuiltInAddon() { + // Enable SCOPE_APPLICATION for builtin testing. Default in tests is only SCOPE_PROFILE. + let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION; + Services.prefs.setIntPref("extensions.enabledScopes", scopes); + + const addon_version = "1.0"; + const addon_res_url_path = "test-builtin-addon"; + + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: addon_version, + browser_specific_settings: { gecko: { id: SYSTEM_ADDON_ID } }, + }, + }); + + // The built-in location requires a resource: URL that maps to a + // jar: or file: URL. This would typically be something bundled + // into omni.ja but for testing we just use a temp file. + let base = Services.io.newURI(`jar:file:${xpi.path}!/`); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution(addon_res_url_path, base); + let builtins = [ + { + addon_id: SYSTEM_ADDON_ID, + addon_version, + res_url: `resource://${addon_res_url_path}/`, + }, + ]; + await AddonTestUtils.overrideBuiltIns({ builtins }); + await AddonTestUtils.promiseStartupManager(); +}); + +const ID1 = "addon1@tests.mozilla.org"; +const ID2 = "addon2@tests.mozilla.org"; +const ID3 = "addon3@tests.mozilla.org"; + +const ADDONS = { + test_addon1: { + manifest: { + browser_specific_settings: { + gecko: { + id: ID1, + update_url: "http://example.com/data/test_install.json", + }, + }, + }, + }, + + test_addon2: { + manifest: { + browser_specific_settings: { gecko: { id: ID2 } }, + }, + }, + + test_addon3: { + manifest: { + browser_specific_settings: { + gecko: { + id: ID3, + strict_max_version: "0", + }, + }, + }, + }, +}; + +const SEARCH_RESULT = { + next: null, + results: [ + { + name: "Test Extension", + type: "extension", + guid: "addon1@tests.mozilla.org", + current_version: { + version: "1.0", + files: [ + { + platform: "all", + size: 485, + url: "http://localhost:8888/addon1.xpi", + }, + ], + }, + last_updated: "2018-10-27T04:12:00.826Z", + }, + ], +}; + +const MISSING_SEARCH_RESULT = { + next: null, + results: [ + { + name: "Test", + type: "extension", + guid: "missing-xpi@tests.mozilla.org", + current_version: { + version: "1.0", + files: [ + { + platform: "all", + size: 123, + url: "http://localhost:8888/THIS_DOES_NOT_EXIST.xpi", + }, + ], + }, + }, + ], +}; + +const AMOSIGNED_SHA1_SEARCH_RESULT = { + next: null, + results: [ + { + name: "Test Extension", + type: "extension", + guid: "amosigned-xpi@tests.mozilla.org", + current_version: { + version: "2.1", + files: [ + { + platform: "all", + size: 4287, + url: "http://localhost:8888/amosigned-sha1only.xpi", + }, + ], + }, + last_updated: "2024-03-21T16:00:06.640Z", + }, + ], +}; + +const XPIS = {}; +for (let [name, files] of Object.entries(ADDONS)) { + XPIS[name] = AddonTestUtils.createTempWebExtensionFile(files); +} + +let engine; +let store; +let reconciler; + +const proxyService = Cc[ + "@mozilla.org/network/protocol-proxy-service;1" +].getService(Ci.nsIProtocolProxyService); + +const proxyFilter = { + proxyInfo: proxyService.newProxyInfo( + "http", + "localhost", + HTTP_PORT, + "", + "", + 0, + 4096, + null + ), + + applyFilter(channel, defaultProxyInfo, callback) { + if (channel.URI.host === "example.com") { + callback.onProxyFilterResult(this.proxyInfo); + } else { + callback.onProxyFilterResult(defaultProxyInfo); + } + }, +}; + +proxyService.registerChannelFilter(proxyFilter, 0); +registerCleanupFunction(() => { + proxyService.unregisterChannelFilter(proxyFilter); +}); + +/** + * Create a AddonsRec for this application with the fields specified. + * + * @param id Sync GUID of record + * @param addonId ID of add-on + * @param enabled Boolean whether record is enabled + * @param deleted Boolean whether record was deleted + */ +function createRecordForThisApp(id, addonId, enabled, deleted) { + return { + id, + addonID: addonId, + enabled, + deleted: !!deleted, + applicationID: Services.appinfo.ID, + source: "amo", + }; +} + +function createAndStartHTTPServer(port) { + try { + let server = new HttpServer(); + + server.registerPathHandler( + "/search/guid:addon1%40tests.mozilla.org", + (req, resp) => { + resp.setHeader("Content-type", "application/json", true); + resp.write(JSON.stringify(SEARCH_RESULT)); + } + ); + server.registerPathHandler( + "/search/guid:missing-xpi%40tests.mozilla.org", + (req, resp) => { + resp.setHeader("Content-type", "application/json", true); + resp.write(JSON.stringify(MISSING_SEARCH_RESULT)); + } + ); + server.registerFile("/addon1.xpi", XPIS.test_addon1); + + server.registerPathHandler( + "/search/guid:amosigned-xpi%40tests.mozilla.org", + (req, resp) => { + resp.setHeader("Content-type", "application/json", true); + resp.write(JSON.stringify(AMOSIGNED_SHA1_SEARCH_RESULT)); + } + ); + server.registerFile( + "/amosigned-sha1only.xpi", + do_get_file("amosigned-sha1only.xpi") + ); + + server.start(port); + + return server; + } catch (ex) { + _("Got exception starting HTTP server on port " + port); + _("Error: " + Log.exceptionStr(ex)); + do_throw(ex); + } + return null; /* not hit, but keeps eslint happy! */ +} + +// A helper function to ensure that the reconciler's current view of the addon +// is the same as the addon itself. If it's not, then the reconciler missed a +// change, and is likely to re-upload the addon next sync because of the change +// it missed. +async function checkReconcilerUpToDate(addon) { + let stateBefore = Object.assign({}, store.reconciler.addons[addon.id]); + await store.reconciler.rectifyStateFromAddon(addon); + let stateAfter = store.reconciler.addons[addon.id]; + deepEqual(stateBefore, stateAfter); +} + +add_setup(async function setup() { + await Service.engineManager.register(AddonsEngine); + engine = Service.engineManager.get("addons"); + store = engine._store; + reconciler = engine._reconciler; + + reconciler.startListening(); + + // Don't flush to disk in the middle of an event listener! + // This causes test hangs on WinXP. + reconciler._shouldPersist = false; +}); + +add_task(async function test_remove() { + _("Ensure removing add-ons from deleted records works."); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + let record = createRecordForThisApp(addon.syncGUID, ID1, true, true); + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(0, failed.length); + Assert.equal(null, countTelemetry.failedReasons); + Assert.equal(0, countTelemetry.incomingCounts.failed); + + let newAddon = await AddonManager.getAddonByID(ID1); + Assert.equal(null, newAddon); +}); + +add_task(async function test_apply_enabled() { + let countTelemetry = new SyncedRecordsTelemetry(); + _("Ensures that changes to the userEnabled flag apply."); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + Assert.ok(addon.isActive); + Assert.ok(!addon.userDisabled); + + _("Ensure application of a disable record works as expected."); + let records = []; + records.push(createRecordForThisApp(addon.syncGUID, ID1, false, false)); + + let [failed] = await Promise.all([ + store.applyIncomingBatch(records, countTelemetry), + AddonTestUtils.promiseAddonEvent("onDisabled"), + ]); + Assert.equal(0, failed.length); + Assert.equal(0, countTelemetry.incomingCounts.failed); + addon = await AddonManager.getAddonByID(ID1); + Assert.ok(addon.userDisabled); + await checkReconcilerUpToDate(addon); + records = []; + + _("Ensure enable record works as expected."); + records.push(createRecordForThisApp(addon.syncGUID, ID1, true, false)); + [failed] = await Promise.all([ + store.applyIncomingBatch(records, countTelemetry), + AddonTestUtils.promiseWebExtensionStartup(ID1), + ]); + Assert.equal(0, failed.length); + Assert.equal(0, countTelemetry.incomingCounts.failed); + addon = await AddonManager.getAddonByID(ID1); + Assert.ok(!addon.userDisabled); + await checkReconcilerUpToDate(addon); + records = []; + + _("Ensure enabled state updates don't apply if the ignore pref is set."); + records.push(createRecordForThisApp(addon.syncGUID, ID1, false, false)); + Svc.PrefBranch.setBoolPref("addons.ignoreUserEnabledChanges", true); + failed = await store.applyIncomingBatch(records, countTelemetry); + Assert.equal(0, failed.length); + Assert.equal(0, countTelemetry.incomingCounts.failed); + addon = await AddonManager.getAddonByID(ID1); + Assert.ok(!addon.userDisabled); + records = []; + + await uninstallAddon(addon, reconciler); + Svc.PrefBranch.clearUserPref("addons.ignoreUserEnabledChanges"); +}); + +add_task(async function test_apply_enabled_appDisabled() { + _( + "Ensures that changes to the userEnabled flag apply when the addon is appDisabled." + ); + + // this addon is appDisabled by default. + let addon = await installAddon(XPIS.test_addon3); + Assert.ok(addon.appDisabled); + Assert.ok(!addon.isActive); + Assert.ok(!addon.userDisabled); + + _("Ensure application of a disable record works as expected."); + store.reconciler.pruneChangesBeforeDate(Date.now() + 10); + store.reconciler._changes = []; + let records = []; + let countTelemetry = new SyncedRecordsTelemetry(); + records.push(createRecordForThisApp(addon.syncGUID, ID3, false, false)); + let failed = await store.applyIncomingBatch(records, countTelemetry); + Assert.equal(0, failed.length); + Assert.equal(0, countTelemetry.incomingCounts.failed); + addon = await AddonManager.getAddonByID(ID3); + Assert.ok(addon.userDisabled); + await checkReconcilerUpToDate(addon); + records = []; + + _("Ensure enable record works as expected."); + records.push(createRecordForThisApp(addon.syncGUID, ID3, true, false)); + failed = await store.applyIncomingBatch(records, countTelemetry); + Assert.equal(0, failed.length); + Assert.equal(0, countTelemetry.incomingCounts.failed); + addon = await AddonManager.getAddonByID(ID3); + Assert.ok(!addon.userDisabled); + await checkReconcilerUpToDate(addon); + records = []; + + await uninstallAddon(addon, reconciler); +}); + +add_task(async function test_ignore_different_appid() { + _( + "Ensure that incoming records with a different application ID are ignored." + ); + + // We test by creating a record that should result in an update. + let addon = await installAddon(XPIS.test_addon1, reconciler); + Assert.ok(!addon.userDisabled); + + let record = createRecordForThisApp(addon.syncGUID, ID1, false, false); + record.applicationID = "FAKE_ID"; + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(0, failed.length); + + let newAddon = await AddonManager.getAddonByID(ID1); + Assert.ok(!newAddon.userDisabled); + + await uninstallAddon(addon, reconciler); +}); + +add_task(async function test_ignore_unknown_source() { + _("Ensure incoming records with unknown source are ignored."); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + + let record = createRecordForThisApp(addon.syncGUID, ID1, false, false); + record.source = "DUMMY_SOURCE"; + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(0, failed.length); + + let newAddon = await AddonManager.getAddonByID(ID1); + Assert.ok(!newAddon.userDisabled); + + await uninstallAddon(addon, reconciler); +}); + +add_task(async function test_apply_uninstall() { + _("Ensures that uninstalling an add-on from a record works."); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + + let records = []; + let countTelemetry = new SyncedRecordsTelemetry(); + records.push(createRecordForThisApp(addon.syncGUID, ID1, true, true)); + let failed = await store.applyIncomingBatch(records, countTelemetry); + Assert.equal(0, failed.length); + Assert.equal(0, countTelemetry.incomingCounts.failed); + + addon = await AddonManager.getAddonByID(ID1); + Assert.equal(null, addon); +}); + +add_task(async function test_addon_syncability() { + _("Ensure isAddonSyncable functions properly."); + + Svc.PrefBranch.setStringPref( + "addons.trustedSourceHostnames", + "addons.mozilla.org,other.example.com" + ); + + Assert.ok(!(await store.isAddonSyncable(null))); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + Assert.ok(await store.isAddonSyncable(addon)); + + let dummy = {}; + const KEYS = [ + "id", + "syncGUID", + "type", + "scope", + "foreignInstall", + "isSyncable", + ]; + for (let k of KEYS) { + dummy[k] = addon[k]; + } + + Assert.ok(await store.isAddonSyncable(dummy)); + + dummy.type = "UNSUPPORTED"; + Assert.ok(!(await store.isAddonSyncable(dummy))); + dummy.type = addon.type; + + dummy.scope = 0; + Assert.ok(!(await store.isAddonSyncable(dummy))); + dummy.scope = addon.scope; + + dummy.isSyncable = false; + Assert.ok(!(await store.isAddonSyncable(dummy))); + dummy.isSyncable = addon.isSyncable; + + dummy.foreignInstall = true; + Assert.ok(!(await store.isAddonSyncable(dummy))); + dummy.foreignInstall = false; + + await uninstallAddon(addon, reconciler); + + Assert.ok(!store.isSourceURITrusted(null)); + + let trusted = [ + "https://addons.mozilla.org/foo", + "https://other.example.com/foo", + ]; + + let untrusted = [ + "http://addons.mozilla.org/foo", // non-https + "ftps://addons.mozilla.org/foo", // non-https + "https://untrusted.example.com/foo", // non-trusted hostname` + ]; + + for (let uri of trusted) { + Assert.ok(store.isSourceURITrusted(Services.io.newURI(uri))); + } + + for (let uri of untrusted) { + Assert.ok(!store.isSourceURITrusted(Services.io.newURI(uri))); + } + + Svc.PrefBranch.setStringPref("addons.trustedSourceHostnames", ""); + for (let uri of trusted) { + Assert.ok(!store.isSourceURITrusted(Services.io.newURI(uri))); + } + + Svc.PrefBranch.setStringPref( + "addons.trustedSourceHostnames", + "addons.mozilla.org" + ); + Assert.ok( + store.isSourceURITrusted( + Services.io.newURI("https://addons.mozilla.org/foo") + ) + ); + + Svc.PrefBranch.clearUserPref("addons.trustedSourceHostnames"); +}); + +add_task(async function test_get_all_ids() { + _("Ensures that getAllIDs() returns an appropriate set."); + + _("Installing two addons."); + // XXX - this test seems broken - at this point, before we've installed the + // addons below, store.getAllIDs() returns all addons installed by previous + // tests, even though those tests uninstalled the addon. + // So if any tests above ever add a new addon ID, they are going to need to + // be added here too. + // Assert.equal(0, Object.keys(store.getAllIDs()).length); + let addon1 = await installAddon(XPIS.test_addon1, reconciler); + let addon2 = await installAddon(XPIS.test_addon2, reconciler); + let addon3 = await installAddon(XPIS.test_addon3, reconciler); + + _("Ensure they're syncable."); + Assert.ok(await store.isAddonSyncable(addon1)); + Assert.ok(await store.isAddonSyncable(addon2)); + Assert.ok(await store.isAddonSyncable(addon3)); + + let ids = await store.getAllIDs(); + + Assert.equal("object", typeof ids); + Assert.equal(3, Object.keys(ids).length); + Assert.ok(addon1.syncGUID in ids); + Assert.ok(addon2.syncGUID in ids); + Assert.ok(addon3.syncGUID in ids); + + await uninstallAddon(addon1, reconciler); + await uninstallAddon(addon2, reconciler); + await uninstallAddon(addon3, reconciler); +}); + +add_task(async function test_change_item_id() { + _("Ensures that changeItemID() works properly."); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + + let oldID = addon.syncGUID; + let newID = Utils.makeGUID(); + + await store.changeItemID(oldID, newID); + + let newAddon = await AddonManager.getAddonByID(ID1); + Assert.notEqual(null, newAddon); + Assert.equal(newID, newAddon.syncGUID); + + await uninstallAddon(newAddon, reconciler); +}); + +add_task(async function test_create() { + _("Ensure creating/installing an add-on from a record works."); + + let server = createAndStartHTTPServer(HTTP_PORT); + + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, ID1, true, false); + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(0, failed.length); + + let newAddon = await AddonManager.getAddonByID(ID1); + Assert.notEqual(null, newAddon); + Assert.equal(guid, newAddon.syncGUID); + Assert.ok(!newAddon.userDisabled); + + await uninstallAddon(newAddon, reconciler); + + await promiseStopServer(server); +}); + +add_task(async function test_weak_signature_restrictions() { + _("Ensure installing add-ons with a weak signature fails when restricted."); + + // Ensure restrictions on weak signatures are enabled (this should be removed when + // the new behavior is riding the train). + const resetWeakSignaturePref = + AddonTestUtils.setWeakSignatureInstallAllowed(false); + const server = createAndStartHTTPServer(HTTP_PORT); + const ID_TEST_SHA1 = "amosigned-xpi@tests.mozilla.org"; + + const guidKO = Utils.makeGUID(); + const guidOK = Utils.makeGUID(); + const recordKO = createRecordForThisApp(guidKO, ID_TEST_SHA1, true, false); + const recordOK = createRecordForThisApp(guidOK, ID1, true, false); + const countTelemetry = new SyncedRecordsTelemetry(); + + let failed; + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + failed = await store.applyIncomingBatch( + [recordKO, recordOK], + countTelemetry + ); + }); + + Assert.equal( + 1, + failed.length, + "Expect only 1 on the two synced add-ons to fail" + ); + + resetWeakSignaturePref(); + + let addonKO = await AddonManager.getAddonByID(ID_TEST_SHA1); + Assert.equal(null, addonKO, `Expect ${ID_TEST_SHA1} to NOT be installed`); + let addonOK = await AddonManager.getAddonByID(ID1); + Assert.notEqual(null, addonOK, `Expect ${ID1} to be installed`); + + await uninstallAddon(addonOK, reconciler); + await promiseStopServer(server); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: + /Download of .*\/amosigned-sha1only.xpi failed: install rejected due to the package not including a strong cryptographic signature/, + }, + ], + }); +}); + +add_task(async function test_create_missing_search() { + _("Ensures that failed add-on searches are handled gracefully."); + + let server = createAndStartHTTPServer(HTTP_PORT); + + // The handler for this ID is not installed, so a search should 404. + const id = "missing@tests.mozilla.org"; + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, id, true, false); + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(1, failed.length); + Assert.equal(guid, failed[0]); + Assert.equal( + countTelemetry.incomingCounts.failedReasons[0].name, + "GET failed (status 404)" + ); + Assert.equal(countTelemetry.incomingCounts.failedReasons[0].count, 1); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(null, addon); + + await promiseStopServer(server); +}); + +add_task(async function test_create_bad_install() { + _("Ensures that add-ons without a valid install are handled gracefully."); + + let server = createAndStartHTTPServer(HTTP_PORT); + + // The handler returns a search result but the XPI will 404. + const id = "missing-xpi@tests.mozilla.org"; + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, id, true, false); + let countTelemetry = new SyncedRecordsTelemetry(); + /* let failed = */ await store.applyIncomingBatch([record], countTelemetry); + // This addon had no source URI so was skipped - but it's not treated as + // failure. + // XXX - this test isn't testing what we thought it was. Previously the addon + // was not being installed due to requireSecureURL checking *before* we'd + // attempted to get the XPI. + // With requireSecureURL disabled we do see a download failure, but the addon + // *does* get added to |failed|. + // FTR: onDownloadFailed() is called with ERROR_NETWORK_FAILURE, so it's going + // to be tricky to distinguish a 404 from other transient network errors + // where we do want the addon to end up in |failed|. + // This is being tracked in bug 1284778. + // Assert.equal(0, failed.length); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(null, addon); + + await promiseStopServer(server); +}); + +add_task(async function test_ignore_system() { + _("Ensure we ignore system addons"); + // Our system addon should not appear in getAllIDs + await engine._refreshReconcilerState(); + let num = 0; + let ids = await store.getAllIDs(); + for (let guid in ids) { + num += 1; + let addon = reconciler.getAddonStateFromSyncGUID(guid); + Assert.notEqual(addon.id, SYSTEM_ADDON_ID); + } + Assert.greater(num, 1, "should have seen at least one."); +}); + +add_task(async function test_incoming_system() { + _("Ensure we handle incoming records that refer to a system addon"); + // eg, loop initially had a normal addon but it was then "promoted" to be a + // system addon but wanted to keep the same ID. The server record exists due + // to this. + + // before we start, ensure the system addon isn't disabled. + Assert.ok(!(await AddonManager.getAddonByID(SYSTEM_ADDON_ID).userDisabled)); + + // Now simulate an incoming record with the same ID as the system addon, + // but flagged as disabled - it should not be applied. + let server = createAndStartHTTPServer(HTTP_PORT); + // We make the incoming record flag the system addon as disabled - it should + // be ignored. + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, SYSTEM_ADDON_ID, false, false); + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(0, failed.length); + + // The system addon should still not be userDisabled. + Assert.ok(!(await AddonManager.getAddonByID(SYSTEM_ADDON_ID).userDisabled)); + + await promiseStopServer(server); +}); + +add_task(async function test_wipe() { + _("Ensures that wiping causes add-ons to be uninstalled."); + + await installAddon(XPIS.test_addon1, reconciler); + + await store.wipe(); + + let addon = await AddonManager.getAddonByID(ID1); + Assert.equal(null, addon); +}); + +add_task(async function test_wipe_and_install() { + _("Ensure wipe followed by install works."); + + // This tests the reset sync flow where remote data is replaced by local. The + // receiving client will see a wipe followed by a record which should undo + // the wipe. + let installed = await installAddon(XPIS.test_addon1, reconciler); + + let record = createRecordForThisApp(installed.syncGUID, ID1, true, false); + + await store.wipe(); + + let deleted = await AddonManager.getAddonByID(ID1); + Assert.equal(null, deleted); + + // Re-applying the record can require re-fetching the XPI. + let server = createAndStartHTTPServer(HTTP_PORT); + + await store.applyIncoming(record); + + let fetched = await AddonManager.getAddonByID(record.addonID); + Assert.ok(!!fetched); + + // wipe again to we are left with a clean slate. + await store.wipe(); + + await promiseStopServer(server); +}); + +// STR for what this is testing: +// * Either: +// * Install then remove an addon, then delete addons.json from the profile +// or corrupt it (in which case the addon manager will remove it) +// * Install then remove an addon while addon caching is disabled, then +// re-enable addon caching. +// * Install the same addon in a different profile, sync it. +// * Sync this profile +// Before bug 1467904, the addon would fail to install because this profile +// has a copy of the addon in our addonsreconciler.json, but the addon manager +// does *not* have a copy in its cache, and repopulating that cache would not +// re-add it as the addon is no longer installed locally. +add_task(async function test_incoming_reconciled_but_not_cached() { + _( + "Ensure we handle incoming records our reconciler has but the addon cache does not" + ); + + // Make sure addon is not installed. + let addon = await AddonManager.getAddonByID(ID1); + Assert.equal(null, addon); + + Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false); + + addon = await installAddon(XPIS.test_addon1, reconciler); + Assert.notEqual(await AddonManager.getAddonByID(ID1), null); + await uninstallAddon(addon, reconciler); + + Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", true); + + // now pretend it is incoming. + let server = createAndStartHTTPServer(HTTP_PORT); + let guid = Utils.makeGUID(); + let record = createRecordForThisApp(guid, ID1, true, false); + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch([record], countTelemetry); + Assert.equal(0, failed.length); + + Assert.notEqual(await AddonManager.getAddonByID(ID1), null); + + await promiseStopServer(server); +}); + +// Helper for testing theme-specific addons +function makeThemeSearchResult(id) { + return { + next: null, + results: [ + { + name: "Sync Theme", + type: "theme", + guid: id, + current_version: { + version: "1.0", + files: [ + { + platform: "all", + size: 1234, + url: `http://localhost:${HTTP_PORT}/synctheme.xpi`, + }, + ], + }, + last_updated: "2025-03-01T00:00:00.000Z", + }, + ], + }; +} + +/** + * Incoming theme add-on record should + * – install the theme + * – enable it immediately + * – clear the hand-off pref + */ +add_task(async function test_incoming_theme_gets_enabled() { + const xpiTheme = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + manifest_version: 2, + name: "Sync Theme", + version: "1.0", + applications: { gecko: { id: THEME_ID } }, + theme: { colors: { frame: "#000000", tab_background_text: "#ffffff" } }, + }, + }); + + const server = createAndStartHTTPServer(HTTP_PORT); + server.registerFile("/synctheme.xpi", xpiTheme); + server.registerPathHandler( + `/search/guid:${encodeURIComponent(THEME_ID)}`, + (req, resp) => { + resp.setHeader("Content-Type", "application/json", true); + resp.write(JSON.stringify(makeThemeSearchResult(THEME_ID))); + } + ); + + // Pretend Prefs‑engine has just synced a new activeThemeID + Services.prefs.setStringPref("extensions.pendingActiveThemeID", THEME_ID); + + // Feed an add-on record into the Addons engine. + const guid = Utils.makeGUID(); + const record = createRecordForThisApp(guid, THEME_ID, true, false); + const telem = new SyncedRecordsTelemetry(); + + const onStartup = AddonTestUtils.promiseWebExtensionStartup(THEME_ID); + const failed = await store.applyIncomingBatch([record], telem); + + Assert.equal(0, failed.length, "No records should fail to apply"); + await onStartup; + + const theme = await AddonManager.getAddonByID(THEME_ID); + Assert.ok(theme, "Theme is installed"); + Assert.ok(theme.isActive, "Theme is active"); + Assert.ok(!theme.userDisabled, "Theme is not user-disabled"); + + Assert.equal( + Services.prefs.getPrefType("extensions.pendingActiveThemeID"), + Ci.nsIPrefBranch.PREF_INVALID, + "Hand-off pref was cleared" + ); + + // Clean-up + await uninstallAddon(theme, reconciler); + await promiseStopServer(server); +}); + +// NOTE: The test above must be the last test run due to the addon cache +// being trashed. It is probably possible to fix that by running, eg, +// AddonRespository.backgroundUpdateCheck() to rebuild the cache, but that +// requires implementing more AMO functionality in our test server + +add_task(async function cleanup() { + // There's an xpcom-shutdown hook for this, but let's give this a shot. + reconciler.stopListening(); +}); diff --git a/src/zen/tests/mochitests/sync/test_addons_tracker.js b/src/zen/tests/mochitests/sync/test_addons_tracker.js new file mode 100644 index 000000000..f8473e4cf --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_addons_tracker.js @@ -0,0 +1,174 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonsEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/addons.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" +); +AddonTestUtils.overrideCertDB(); + +Services.prefs.setBoolPref("extensions.experiments.enabled", true); + +Svc.PrefBranch.setBoolPref("engine.addons", true); + +let reconciler; +let tracker; + +const addon1ID = "addon1@tests.mozilla.org"; + +const ADDONS = { + test_addon1: { + manifest: { + browser_specific_settings: { gecko: { id: addon1ID } }, + }, + }, +}; + +const XPIS = {}; + +async function cleanup() { + tracker.stop(); + + tracker.resetScore(); + await tracker.clearChangedIDs(); + + reconciler._addons = {}; + reconciler._changes = []; + await reconciler.saveState(); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + for (let [name, data] of Object.entries(ADDONS)) { + XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data); + } + await Service.engineManager.register(AddonsEngine); + let engine = Service.engineManager.get("addons"); + reconciler = engine._reconciler; + tracker = engine._tracker; + + await cleanup(); +}); + +add_task(async function test_empty() { + _("Verify the tracker is empty to start with."); + + Assert.equal(0, Object.keys(await tracker.getChangedIDs()).length); + Assert.equal(0, tracker.score); + + await cleanup(); +}); + +add_task(async function test_not_tracking() { + _("Ensures the tracker doesn't do anything when it isn't tracking."); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + await uninstallAddon(addon, reconciler); + + Assert.equal(0, Object.keys(await tracker.getChangedIDs()).length); + Assert.equal(0, tracker.score); + + await cleanup(); +}); + +add_task(async function test_track_install() { + _("Ensure that installing an add-on notifies tracker."); + + reconciler.startListening(); + + tracker.start(); + + Assert.equal(0, tracker.score); + let addon = await installAddon(XPIS.test_addon1, reconciler); + let changed = await tracker.getChangedIDs(); + + Assert.equal(1, Object.keys(changed).length); + Assert.ok(addon.syncGUID in changed); + Assert.equal(SCORE_INCREMENT_XLARGE, tracker.score); + + await uninstallAddon(addon, reconciler); + await cleanup(); +}); + +add_task(async function test_track_uninstall() { + _("Ensure that uninstalling an add-on notifies tracker."); + + reconciler.startListening(); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + let guid = addon.syncGUID; + Assert.equal(0, tracker.score); + + tracker.start(); + + await uninstallAddon(addon, reconciler); + let changed = await tracker.getChangedIDs(); + Assert.equal(1, Object.keys(changed).length); + Assert.ok(guid in changed); + Assert.equal(SCORE_INCREMENT_XLARGE, tracker.score); + + await cleanup(); +}); + +add_task(async function test_track_user_disable() { + _("Ensure that tracker sees disabling of add-on"); + + reconciler.startListening(); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + Assert.ok(!addon.userDisabled); + Assert.ok(!addon.appDisabled); + Assert.ok(addon.isActive); + + tracker.start(); + Assert.equal(0, tracker.score); + + _("Disabling add-on"); + await addon.disable(); + await reconciler.queueCaller.promiseCallsComplete(); + + let changed = await tracker.getChangedIDs(); + Assert.equal(1, Object.keys(changed).length); + Assert.ok(addon.syncGUID in changed); + Assert.equal(SCORE_INCREMENT_XLARGE, tracker.score); + + await uninstallAddon(addon, reconciler); + await cleanup(); +}); + +add_task(async function test_track_enable() { + _("Ensure that enabling a disabled add-on notifies tracker."); + + reconciler.startListening(); + + let addon = await installAddon(XPIS.test_addon1, reconciler); + await addon.disable(); + await Async.promiseYield(); + + Assert.equal(0, tracker.score); + + tracker.start(); + await addon.enable(); + await Async.promiseYield(); + await reconciler.queueCaller.promiseCallsComplete(); + + let changed = await tracker.getChangedIDs(); + Assert.equal(1, Object.keys(changed).length); + Assert.ok(addon.syncGUID in changed); + Assert.equal(SCORE_INCREMENT_XLARGE, tracker.score); + + await uninstallAddon(addon, reconciler); + await cleanup(); +}); diff --git a/src/zen/tests/mochitests/sync/test_addons_validator.js b/src/zen/tests/mochitests/sync/test_addons_validator.js new file mode 100644 index 000000000..91f3f7b31 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_addons_validator.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { AddonValidator } = ChromeUtils.importESModule( + "resource://services-sync/engines/addons.sys.mjs" +); + +function getDummyServerAndClient() { + return { + server: [ + { + id: "1111", + applicationID: Services.appinfo.ID, + addonID: "synced-addon@example.com", + enabled: true, + source: "amo", + understood: true, + }, + ], + client: [ + { + syncGUID: "1111", + id: "synced-addon@example.com", + type: "extension", + isSystem: false, + isSyncable: true, + }, + { + syncGUID: "2222", + id: "system-addon@example.com", + type: "extension", + isSystem: true, + isSyncable: false, + }, + { + // Plugins don't have a `syncedGUID`, but we don't sync them, so we + // shouldn't report them as client duplicates. + id: "some-plugin", + type: "plugin", + }, + { + id: "another-plugin", + type: "plugin", + }, + ], + }; +} + +add_task(async function test_valid() { + let { server, client } = getDummyServerAndClient(); + let validator = new AddonValidator({ + _findDupe() { + return null; + }, + isAddonSyncable(item) { + return item.type != "plugin"; + }, + }); + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + equal(clientRecords.length, 4); + equal(records.length, 1); + equal(deletedRecords.length, 0); + deepEqual(problemData, validator.emptyProblemData()); +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_batch_fail.js b/src/zen/tests/mochitests/sync/test_bookmark_batch_fail.js new file mode 100644 index 000000000..9644d730e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_batch_fail.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Making sure a failing sync reports a useful error"); +// `Service` is used as a global in head_helpers.js. +// eslint-disable-next-line no-unused-vars +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_bookmark_test(async function run_test(engine) { + await engine.initialize(); + engine._syncStartup = async function () { + throw new Error("FAIL!"); + }; + + try { + _("Try calling the sync that should throw right away"); + await engine._sync(); + do_throw("Should have failed sync!"); + } catch (ex) { + _("Making sure what we threw ended up as the exception:", ex); + Assert.equal(ex.message, "FAIL!"); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_decline_undecline.js b/src/zen/tests/mochitests/sync/test_bookmark_decline_undecline.js new file mode 100644 index 000000000..12139dd16 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_decline_undecline.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +// A stored reference to the collection won't be valid after disabling. +function getBookmarkWBO(server, guid) { + let coll = server.user("foo").collection("bookmarks"); + if (!coll) { + return null; + } + return coll.wbo(guid); +} + +add_task(async function test_decline_undecline() { + let engine = Service.engineManager.get("bookmarks"); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + let { guid: bzGuid } = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://bugzilla.mozilla.org", + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + title: "bugzilla", + }); + + ok(!getBookmarkWBO(server, bzGuid), "Shouldn't have been uploaded yet"); + await Service.sync(); + ok(getBookmarkWBO(server, bzGuid), "Should be present on server"); + + engine.enabled = false; + await Service.sync(); + ok( + !getBookmarkWBO(server, bzGuid), + "Shouldn't be present on server anymore" + ); + + engine.enabled = true; + await Service.sync(); + ok(getBookmarkWBO(server, bzGuid), "Should be present on server again"); + } finally { + await PlacesSyncUtils.bookmarks.reset(); + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_engine.js b/src/zen/tests/mochitests/sync/test_bookmark_engine.js new file mode 100644 index 000000000..e08847195 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_engine.js @@ -0,0 +1,1508 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" +); +const { BookmarkJSONUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkJSONUtils.sys.mjs" +); +const { Bookmark, BookmarkFolder, BookmarksEngine, Livemark } = + ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" + ); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { SyncedRecordsTelemetry } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" +); + +async function fetchAllRecordIds() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached(` + WITH RECURSIVE + syncedItems(id, guid) AS ( + SELECT b.id, b.guid FROM moz_bookmarks b + WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____', + 'mobile______') + UNION ALL + SELECT b.id, b.guid FROM moz_bookmarks b + JOIN syncedItems s ON b.parent = s.id + ) + SELECT guid FROM syncedItems`); + let recordIds = new Set(); + for (let row of rows) { + let recordId = PlacesSyncUtils.bookmarks.guidToRecordId( + row.getResultByName("guid") + ); + recordIds.add(recordId); + } + return recordIds; +} + +async function cleanupEngine(engine) { + await engine.resetClient(); + await engine._store.wipe(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + // Note we don't finalize the engine here as add_bookmark_test() does. +} + +async function cleanup(engine, server) { + await promiseStopServer(server); + await cleanupEngine(engine); +} + +add_setup(async function () { + await generateNewKeys(Service.collectionKeys); + await Service.engineManager.unregister("bookmarks"); + + do_get_profile(); // FOG needs a profile dir + Services.fog.initializeFOG(); +}); + +add_task(async function test_buffer_timeout() { + await Service.recordManager.clearCache(); + await PlacesSyncUtils.bookmarks.reset(); + let engine = new BookmarksEngine(Service); + engine._newWatchdog = function () { + // Return an already-aborted watchdog, so that we can abort merges + // immediately. + let watchdog = Async.watchdog(); + watchdog.controller.abort(); + return watchdog; + }; + await engine.initialize(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("bookmarks"); + + try { + info("Insert local bookmarks"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + ], + }); + + info("Insert remote bookmarks"); + collection.insert( + "menu", + encryptPayload({ + id: "menu", + type: "folder", + parentid: "places", + title: "menu", + children: ["bookmarkCCCC", "bookmarkDDDD"], + }) + ); + collection.insert( + "bookmarkCCCC", + encryptPayload({ + id: "bookmarkCCCC", + type: "bookmark", + parentid: "menu", + bmkUri: "http://example.com/c", + title: "C", + }) + ); + collection.insert( + "bookmarkDDDD", + encryptPayload({ + id: "bookmarkDDDD", + type: "bookmark", + parentid: "menu", + bmkUri: "http://example.com/d", + title: "D", + }) + ); + + info("We expect this sync to fail"); + await Assert.rejects( + sync_engine_and_validate_telem(engine, true), + ex => ex.name == "InterruptedError" + ); + } finally { + await cleanup(engine, server); + await engine.finalize(); + } +}); + +add_bookmark_test(async function test_maintenance_after_failure(engine) { + _("Ensure we try to run maintenance if the engine fails to sync"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + let syncStartup = engine._syncStartup; + let syncError = new Error("Something is rotten in the state of Places"); + engine._syncStartup = function () { + throw syncError; + }; + + Services.prefs.clearUserPref("places.database.lastMaintenance"); + + _("Ensure the sync fails and we run maintenance"); + await Assert.rejects( + sync_engine_and_validate_telem(engine, true), + ex => ex == syncError + ); + Assert.equal(Glean.sync.maintenanceRunBookmarks.testGetValue().length, 1); + Services.fog.testResetFOG(); + + _("Sync again, but ensure maintenance doesn't run"); + await Assert.rejects( + sync_engine_and_validate_telem(engine, true), + ex => ex == syncError + ); + Assert.equal(Glean.sync.maintenanceRunBookmarks.testGetValue(), null); + Services.fog.testResetFOG(); + + _("Fast-forward last maintenance pref; ensure maintenance runs"); + Services.prefs.setIntPref( + "places.database.lastMaintenance", + Date.now() / 1000 - 14400 + ); + await Assert.rejects( + sync_engine_and_validate_telem(engine, true), + ex => ex == syncError + ); + Assert.equal(Glean.sync.maintenanceRunBookmarks.testGetValue().length, 1); + Services.fog.testResetFOG(); + + _("Fix sync failure; ensure we report success after maintenance"); + engine._syncStartup = syncStartup; + await sync_engine_and_validate_telem(engine, false); + Assert.equal(Glean.sync.maintenanceFixBookmarks.testGetValue().length, 1); + Services.fog.testResetFOG(); + + await sync_engine_and_validate_telem(engine, false); + Assert.equal(Glean.sync.maintenanceFixBookmarks.testGetValue(), null); + Services.fog.testResetFOG(); + } finally { + await cleanup(engine, server); + } +}); + +add_bookmark_test(async function test_delete_invalid_roots_from_server(engine) { + _("Ensure that we delete the Places and Reading List roots from the server."); + + enableValidationPrefs(); + + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + + engine._tracker.start(); + + try { + let placesRecord = await store.createRecord("places"); + collection.insert("places", encryptPayload(placesRecord.cleartext)); + + let listBmk = new Bookmark("bookmarks", Utils.makeGUID()); + listBmk.bmkUri = "https://example.com"; + listBmk.title = "Example reading list entry"; + listBmk.parentName = "Reading List"; + listBmk.parentid = "readinglist"; + collection.insert(listBmk.id, encryptPayload(listBmk.cleartext)); + + let readingList = new BookmarkFolder("bookmarks", "readinglist"); + readingList.title = "Reading List"; + readingList.children = [listBmk.id]; + readingList.parentName = ""; + readingList.parentid = "places"; + collection.insert("readinglist", encryptPayload(readingList.cleartext)); + + // Note that we don't insert a record for the toolbar, so the engine will + // report a parent-child disagreement, since Firefox's `parentid` is + // `toolbar`. + let newBmk = new Bookmark("bookmarks", Utils.makeGUID()); + newBmk.bmkUri = "http://getfirefox.com"; + newBmk.title = "Get Firefox!"; + newBmk.parentName = "Bookmarks Toolbar"; + newBmk.parentid = "toolbar"; + collection.insert(newBmk.id, encryptPayload(newBmk.cleartext)); + + deepEqual( + collection.keys().sort(), + ["places", "readinglist", listBmk.id, newBmk.id].sort(), + "Should store Places root, reading list items, and new bookmark on server" + ); + + let ping = await sync_engine_and_validate_telem(engine, true); + // In a real sync, the engine is named `bookmarks-buffered`. + // However, `sync_engine_and_validate_telem` simulates a sync where + // the engine isn't registered with the engine manager, so the recorder + // doesn't see its `overrideTelemetryName`. + let engineData = ping.engines.find(e => e.name == "bookmarks"); + ok(engineData.validation, "Bookmarks engine should always run validation"); + equal( + engineData.validation.checked, + 6, + "Bookmarks engine should validate all items" + ); + deepEqual( + engineData.validation.problems, + [ + { + name: "parentChildDisagreements", + count: 1, + }, + ], + "Bookmarks engine should report parent-child disagreement" + ); + deepEqual( + engineData.steps.map(step => step.name), + [ + "fetchLocalTree", + "fetchRemoteTree", + "merge", + "apply", + "notifyObservers", + "fetchLocalChangeRecords", + ], + "Bookmarks engine should report all merge steps" + ); + + deepEqual( + collection.keys().sort(), + ["menu", "mobile", "toolbar", "unfiled", newBmk.id].sort(), + "Should remove Places root and reading list items from server; upload local roots" + ); + } finally { + await cleanup(engine, server); + } +}); + +add_bookmark_test( + async function test_processIncoming_error_orderChildren(engine) { + _( + "Ensure that _orderChildren() is called even when _processIncoming() throws an error." + ); + + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + + try { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Folder 1", + }); + + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + let bmk2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }); + + let toolbar_record = await store.createRecord("toolbar"); + collection.insert("toolbar", encryptPayload(toolbar_record.cleartext)); + + let bmk1_record = await store.createRecord(bmk1.guid); + collection.insert(bmk1.guid, encryptPayload(bmk1_record.cleartext)); + + let bmk2_record = await store.createRecord(bmk2.guid); + collection.insert(bmk2.guid, encryptPayload(bmk2_record.cleartext)); + + // Create a server record for folder1 where we flip the order of + // the children. + let folder1_record = await store.createRecord(folder1.guid); + let folder1_payload = folder1_record.cleartext; + folder1_payload.children.reverse(); + collection.insert(folder1.guid, encryptPayload(folder1_payload)); + + // Create a bogus record that when synced down will provoke a + // network error which in turn provokes an exception in _processIncoming. + const BOGUS_GUID = "zzzzzzzzzzzz"; + let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!"); + bogus_record.get = function get() { + throw new Error("Sync this!"); + }; + + // Make the 10 minutes old so it will only be synced in the toFetch phase. + bogus_record.modified = new_timestamp() - 60 * 10; + await engine.setLastSync(new_timestamp() - 60); + engine.toFetch = new SerializableSet([BOGUS_GUID]); + + let error; + try { + await sync_engine_and_validate_telem(engine, true); + } catch (ex) { + error = ex; + } + ok(!!error); + + // Verify that the bookmark order has been applied. + folder1_record = await store.createRecord(folder1.guid); + let new_children = folder1_record.children; + Assert.deepEqual( + new_children.sort(), + [folder1_payload.children[0], folder1_payload.children[1]].sort() + ); + + let localChildIds = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + folder1.guid + ); + Assert.deepEqual(localChildIds.sort(), [bmk2.guid, bmk1.guid].sort()); + } finally { + await cleanup(engine, server); + } + } +); + +add_bookmark_test(async function test_restorePromptsReupload(engine) { + await test_restoreOrImport(engine, { replace: true }); +}); + +add_bookmark_test(async function test_importPromptsReupload(engine) { + await test_restoreOrImport(engine, { replace: false }); +}); + +// Test a JSON restore or HTML import. Use JSON if `replace` is `true`, or +// HTML otherwise. +async function test_restoreOrImport(engine, { replace }) { + let verb = replace ? "restore" : "import"; + let verbing = replace ? "restoring" : "importing"; + let bookmarkUtils = replace ? BookmarkJSONUtils : BookmarkHTMLUtils; + + _(`Ensure that ${verbing} from a backup will reupload all records.`); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + + engine._tracker.start(); // We skip usual startup... + + try { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Folder 1", + }); + + _("Create a single record."); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + _(`Get Firefox!: ${bmk1.guid}`); + + let backupFilePath = PathUtils.join( + PathUtils.tempDir, + `t_b_e_${Date.now()}.json` + ); + + _("Make a backup."); + + await bookmarkUtils.exportToFile(backupFilePath); + + _("Create a different record and sync."); + let bmk2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }); + _(`Get Thunderbird!: ${bmk2.guid}`); + + await PlacesUtils.bookmarks.remove(bmk1.guid); + + let error; + try { + await sync_engine_and_validate_telem(engine, false); + } catch (ex) { + error = ex; + _("Got error: " + Log.exceptionStr(ex)); + } + Assert.ok(!error); + + _( + "Verify that there's only one bookmark on the server, and it's Thunderbird." + ); + // Of course, there's also the Bookmarks Toolbar and Bookmarks Menu... + let wbos = collection.keys(function (id) { + return !["menu", "toolbar", "mobile", "unfiled", folder1.guid].includes( + id + ); + }); + Assert.equal(wbos.length, 1); + Assert.equal(wbos[0], bmk2.guid); + + _(`Now ${verb} from a backup.`); + await bookmarkUtils.importFromFile(backupFilePath, { replace }); + + // If `replace` is `true`, we'll wipe the server on the next sync. + let bookmarksCollection = server.user("foo").collection("bookmarks"); + _("Verify that we didn't wipe the server."); + Assert.ok(!!bookmarksCollection); + + _("Ensure we have the bookmarks we expect locally."); + let recordIds = await fetchAllRecordIds(); + _("GUIDs: " + JSON.stringify([...recordIds])); + + let bookmarkRecordIds = new Map(); + let count = 0; + for (let recordId of recordIds) { + count++; + let info = await PlacesUtils.bookmarks.fetch( + PlacesSyncUtils.bookmarks.recordIdToGuid(recordId) + ); + // Only one bookmark, so _all_ should be Firefox! + if (info.type == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + _(`Found URI ${info.url.href} for record ID ${recordId}`); + bookmarkRecordIds.set(info.url.href, recordId); + } + } + Assert.ok(bookmarkRecordIds.has("http://getfirefox.com/")); + if (!replace) { + Assert.ok(bookmarkRecordIds.has("http://getthunderbird.com/")); + } + + _("Have the correct number of IDs locally, too."); + let expectedResults = [ + "menu", + "toolbar", + "mobile", + "unfiled", + folder1.guid, + bmk1.guid, + ]; + if (!replace) { + expectedResults.push("toolbar", folder1.guid, bmk2.guid); + } + Assert.equal(count, expectedResults.length); + + _("Sync again. This'll wipe bookmarks from the server."); + try { + await sync_engine_and_validate_telem(engine, false); + } catch (ex) { + error = ex; + _("Got error: " + Log.exceptionStr(ex)); + } + Assert.ok(!error); + + _("Verify that there's the right bookmarks on the server."); + // Of course, there's also the Bookmarks Toolbar and Bookmarks Menu... + let payloads = server.user("foo").collection("bookmarks").payloads(); + let bookmarkWBOs = payloads.filter(function (wbo) { + return wbo.type == "bookmark"; + }); + + let folderWBOs = payloads.filter(function (wbo) { + return ( + wbo.type == "folder" && + wbo.id != "menu" && + wbo.id != "toolbar" && + wbo.id != "unfiled" && + wbo.id != "mobile" && + wbo.parentid != "menu" + ); + }); + + let expectedFX = { + id: bookmarkRecordIds.get("http://getfirefox.com/"), + bmkUri: "http://getfirefox.com/", + title: "Get Firefox!", + }; + let expectedTB = { + id: bookmarkRecordIds.get("http://getthunderbird.com/"), + bmkUri: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }; + + let expectedBookmarks; + if (replace) { + expectedBookmarks = [expectedFX]; + } else { + expectedBookmarks = [expectedTB, expectedFX]; + } + + doCheckWBOs(bookmarkWBOs, expectedBookmarks); + + _("Our old friend Folder 1 is still in play."); + let expectedFolder1 = { title: "Folder 1" }; + + let expectedFolders; + if (replace) { + expectedFolders = [expectedFolder1]; + } else { + expectedFolders = [expectedFolder1, expectedFolder1]; + } + + doCheckWBOs(folderWBOs, expectedFolders); + } finally { + await cleanup(engine, server); + } +} + +function doCheckWBOs(WBOs, expected) { + Assert.equal(WBOs.length, expected.length); + for (let i = 0; i < expected.length; i++) { + let lhs = WBOs[i]; + let rhs = expected[i]; + if ("id" in rhs) { + Assert.equal(lhs.id, rhs.id); + } + if ("bmkUri" in rhs) { + Assert.equal(lhs.bmkUri, rhs.bmkUri); + } + if ("title" in rhs) { + Assert.equal(lhs.title, rhs.title); + } + } +} + +function FakeRecord(constructor, r) { + this.defaultCleartext = constructor.prototype.defaultCleartext; + constructor.call(this, "bookmarks", r.id); + for (let x in r) { + this[x] = r[x]; + } + // Borrow the constructor's conversion functions. + this.toSyncBookmark = constructor.prototype.toSyncBookmark; + this.cleartextToString = constructor.prototype.cleartextToString; +} + +// Bug 632287. +// (Note that `test_mismatched_folder_types()` in +// toolkit/components/places/tests/sync/test_bookmark_kinds.js is an exact +// copy of this test, so it's fine to remove it as part of bug 1449730) +add_task(async function test_mismatched_types() { + _( + "Ensure that handling a record that changes type causes deletion " + + "then re-adding." + ); + + let oldRecord = { + id: "l1nZZXfB8nC7", + type: "folder", + parentName: "Bookmarks Toolbar", + title: "Innerst i Sneglehode", + description: null, + parentid: "toolbar", + }; + + let newRecord = { + id: "l1nZZXfB8nC7", + type: "livemark", + siteUri: "http://sneglehode.wordpress.com/", + feedUri: "http://sneglehode.wordpress.com/feed/", + parentName: "Bookmarks Toolbar", + title: "Innerst i Sneglehode", + description: null, + children: [ + "HCRq40Rnxhrd", + "YeyWCV1RVsYw", + "GCceVZMhvMbP", + "sYi2hevdArlF", + "vjbZlPlSyGY8", + "UtjUhVyrpeG6", + "rVq8WMG2wfZI", + "Lx0tcy43ZKhZ", + "oT74WwV8_j4P", + "IztsItWVSo3-", + ], + parentid: "toolbar", + }; + + let engine = new BookmarksEngine(Service); + await engine.initialize(); + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + let oldR = new FakeRecord(BookmarkFolder, oldRecord); + let newR = new FakeRecord(Livemark, newRecord); + oldR.parentid = PlacesUtils.bookmarks.toolbarGuid; + newR.parentid = PlacesUtils.bookmarks.toolbarGuid; + + await store.applyIncoming(oldR); + await engine._apply(); + _("Applied old. It's a folder."); + let oldID = await PlacesTestUtils.promiseItemId(oldR.id); + _("Old ID: " + oldID); + let oldInfo = await PlacesUtils.bookmarks.fetch(oldR.id); + Assert.equal(oldInfo.type, PlacesUtils.bookmarks.TYPE_FOLDER); + + await store.applyIncoming(newR); + await engine._apply(); + } finally { + await cleanup(engine, server); + await engine.finalize(); + } +}); + +add_bookmark_test(async function test_misreconciled_root(engine) { + _("Ensure that we don't reconcile an arbitrary record with a root."); + + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + // Log real hard for this test. + store._log.trace = store._log.debug; + engine._log.trace = engine._log.debug; + + await engine._syncStartup(); + + // Let's find out where the toolbar is right now. + let toolbarBefore = await store.createRecord("toolbar", "bookmarks"); + let toolbarIDBefore = await PlacesTestUtils.promiseItemId( + PlacesUtils.bookmarks.toolbarGuid + ); + Assert.notEqual(-1, toolbarIDBefore); + + let parentRecordIDBefore = toolbarBefore.parentid; + let parentGUIDBefore = + PlacesSyncUtils.bookmarks.recordIdToGuid(parentRecordIDBefore); + let parentIDBefore = await PlacesTestUtils.promiseItemId(parentGUIDBefore); + Assert.equal("string", typeof parentGUIDBefore); + + _("Current parent: " + parentGUIDBefore + " (" + parentIDBefore + ")."); + + let to_apply = { + id: "zzzzzzzzzzzz", + type: "folder", + title: "Bookmarks Toolbar", + description: "Now you're for it.", + parentName: "", + parentid: "mobile", // Why not? + children: [], + }; + + let rec = new FakeRecord(BookmarkFolder, to_apply); + + _("Applying record."); + let countTelemetry = new SyncedRecordsTelemetry(); + await store.applyIncomingBatch([rec], countTelemetry); + + // Ensure that afterwards, toolbar is still there. + // As of 2012-12-05, this only passes because Places doesn't use "toolbar" as + // the real GUID, instead using a generated one. Sync does the translation. + let toolbarAfter = await store.createRecord("toolbar", "bookmarks"); + let parentRecordIDAfter = toolbarAfter.parentid; + let parentGUIDAfter = + PlacesSyncUtils.bookmarks.recordIdToGuid(parentRecordIDAfter); + let parentIDAfter = await PlacesTestUtils.promiseItemId(parentGUIDAfter); + Assert.equal( + await PlacesTestUtils.promiseItemGuid(toolbarIDBefore), + PlacesUtils.bookmarks.toolbarGuid + ); + Assert.equal(parentGUIDBefore, parentGUIDAfter); + Assert.equal(parentIDBefore, parentIDAfter); + + await cleanup(engine, server); +}); + +add_bookmark_test(async function test_invalid_url(engine) { + _("Ensure an incoming invalid bookmark URL causes an outgoing tombstone."); + + let server = await serverForFoo(engine); + let collection = server.user("foo").collection("bookmarks"); + + await SyncTestingInfrastructure(server); + await engine._syncStartup(); + + // check the URL really is invalid. + let url = "https://www.42registry.42/"; + Assert.throws(() => Services.io.newURI(url), /invalid/); + + let guid = "abcdefabcdef"; + + let toolbar = new BookmarkFolder("bookmarks", "toolbar"); + toolbar.title = "toolbar"; + toolbar.parentName = ""; + toolbar.parentid = "places"; + toolbar.children = [guid]; + collection.insert("toolbar", encryptPayload(toolbar.cleartext)); + + let item1 = new Bookmark("bookmarks", guid); + item1.bmkUri = "https://www.42registry.42/"; + item1.title = "invalid url"; + item1.parentName = "Bookmarks Toolbar"; + item1.parentid = "toolbar"; + item1.dateAdded = 1234; + collection.insert(guid, encryptPayload(item1.cleartext)); + + _("syncing."); + await sync_engine_and_validate_telem(engine, false); + + // We should find the record now exists on the server as a tombstone. + let updated = collection.cleartext(guid); + Assert.ok(updated.deleted, "record was deleted"); + + let local = await PlacesUtils.bookmarks.fetch(guid); + Assert.deepEqual(local, null, "no local bookmark exists"); + + await cleanup(engine, server); +}); + +add_bookmark_test(async function test_sync_dateAdded(engine) { + await Service.recordManager.clearCache(); + await PlacesSyncUtils.bookmarks.reset(); + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + + // TODO: Avoid random orange (bug 1374599), this is only necessary + // intermittently - reset the last sync date so that we'll get all bookmarks. + await engine.setLastSync(1); + + engine._tracker.start(); // We skip usual startup... + + // Just matters that it's in the past, not how far. + let now = Date.now(); + let oneYearMS = 365 * 24 * 60 * 60 * 1000; + + try { + let toolbar = new BookmarkFolder("bookmarks", "toolbar"); + toolbar.title = "toolbar"; + toolbar.parentName = ""; + toolbar.parentid = "places"; + toolbar.children = [ + "abcdefabcdef", + "aaaaaaaaaaaa", + "bbbbbbbbbbbb", + "cccccccccccc", + "dddddddddddd", + "eeeeeeeeeeee", + ]; + collection.insert("toolbar", encryptPayload(toolbar.cleartext)); + + let item1GUID = "abcdefabcdef"; + let item1 = new Bookmark("bookmarks", item1GUID); + item1.bmkUri = "https://example.com"; + item1.title = "asdf"; + item1.parentName = "Bookmarks Toolbar"; + item1.parentid = "toolbar"; + item1.dateAdded = now - oneYearMS; + collection.insert(item1GUID, encryptPayload(item1.cleartext)); + + let item2GUID = "aaaaaaaaaaaa"; + let item2 = new Bookmark("bookmarks", item2GUID); + item2.bmkUri = "https://example.com/2"; + item2.title = "asdf2"; + item2.parentName = "Bookmarks Toolbar"; + item2.parentid = "toolbar"; + item2.dateAdded = now + oneYearMS; + const item2LastModified = now / 1000 - 100; + collection.insert( + item2GUID, + encryptPayload(item2.cleartext), + item2LastModified + ); + + let item3GUID = "bbbbbbbbbbbb"; + let item3 = new Bookmark("bookmarks", item3GUID); + item3.bmkUri = "https://example.com/3"; + item3.title = "asdf3"; + item3.parentName = "Bookmarks Toolbar"; + item3.parentid = "toolbar"; + // no dateAdded + collection.insert(item3GUID, encryptPayload(item3.cleartext)); + + let item4GUID = "cccccccccccc"; + let item4 = new Bookmark("bookmarks", item4GUID); + item4.bmkUri = "https://example.com/4"; + item4.title = "asdf4"; + item4.parentName = "Bookmarks Toolbar"; + item4.parentid = "toolbar"; + // no dateAdded, but lastModified in past + const item4LastModified = (now - oneYearMS) / 1000; + collection.insert( + item4GUID, + encryptPayload(item4.cleartext), + item4LastModified + ); + + let item5GUID = "dddddddddddd"; + let item5 = new Bookmark("bookmarks", item5GUID); + item5.bmkUri = "https://example.com/5"; + item5.title = "asdf5"; + item5.parentName = "Bookmarks Toolbar"; + item5.parentid = "toolbar"; + // no dateAdded, lastModified in (near) future. + const item5LastModified = (now + 60000) / 1000; + collection.insert( + item5GUID, + encryptPayload(item5.cleartext), + item5LastModified + ); + + let item6GUID = "eeeeeeeeeeee"; + let item6 = new Bookmark("bookmarks", item6GUID); + item6.bmkUri = "https://example.com/6"; + item6.title = "asdf6"; + item6.parentName = "Bookmarks Toolbar"; + item6.parentid = "toolbar"; + const item6LastModified = (now - oneYearMS) / 1000; + collection.insert( + item6GUID, + encryptPayload(item6.cleartext), + item6LastModified + ); + + await sync_engine_and_validate_telem(engine, false); + + let record1 = await store.createRecord(item1GUID); + let record2 = await store.createRecord(item2GUID); + + equal( + item1.dateAdded, + record1.dateAdded, + "dateAdded in past should be synced" + ); + equal( + record2.dateAdded, + item2LastModified * 1000, + "dateAdded in future should be ignored in favor of last modified" + ); + + let record3 = await store.createRecord(item3GUID); + + ok(record3.dateAdded); + // Make sure it's within 24 hours of the right timestamp... This is a little + // dodgey but we only really care that it's basically accurate and has the + // right day. + Assert.less(Math.abs(Date.now() - record3.dateAdded), 24 * 60 * 60 * 1000); + + let record4 = await store.createRecord(item4GUID); + equal( + record4.dateAdded, + item4LastModified * 1000, + "If no dateAdded is provided, lastModified should be used" + ); + + let record5 = await store.createRecord(item5GUID); + equal( + record5.dateAdded, + item5LastModified * 1000, + "If no dateAdded is provided, lastModified should be used (even if it's in the future)" + ); + + // Update item2 and try resyncing it. + item2.dateAdded = now - 100000; + collection.insert( + item2GUID, + encryptPayload(item2.cleartext), + now / 1000 - 50 + ); + + // Also, add a local bookmark and make sure its date added makes it up to the server + let bz = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://bugzilla.mozilla.org/", + title: "Bugzilla", + }); + + // last sync did a POST, which doesn't advance its lastModified value. + // Next sync of the engine doesn't hit info/collections, so lastModified + // remains stale. Setting it to null side-steps that. + engine.lastModified = null; + await sync_engine_and_validate_telem(engine, false); + + let newRecord2 = await store.createRecord(item2GUID); + equal( + newRecord2.dateAdded, + item2.dateAdded, + "dateAdded update should work for earlier date" + ); + + let bzWBO = collection.cleartext(bz.guid); + ok(bzWBO.dateAdded, "Locally added dateAdded lost"); + + let localRecord = await store.createRecord(bz.guid); + equal( + bzWBO.dateAdded, + localRecord.dateAdded, + "dateAdded should not change during upload" + ); + + item2.dateAdded += 10000; + collection.insert( + item2GUID, + encryptPayload(item2.cleartext), + now / 1000 - 10 + ); + + engine.lastModified = null; + await sync_engine_and_validate_telem(engine, false); + + let newerRecord2 = await store.createRecord(item2GUID); + equal( + newerRecord2.dateAdded, + newRecord2.dateAdded, + "dateAdded update should be ignored for later date if we know an earlier one " + ); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_buffer_hasDupe() { + await Service.recordManager.clearCache(); + await PlacesSyncUtils.bookmarks.reset(); + let engine = new BookmarksEngine(Service); + await engine.initialize(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("bookmarks"); + engine._tracker.start(); // We skip usual startup... + try { + let guid1 = Utils.makeGUID(); + let guid2 = Utils.makeGUID(); + await PlacesUtils.bookmarks.insert({ + guid: guid1, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "https://www.example.com", + title: "example.com", + }); + await PlacesUtils.bookmarks.insert({ + guid: guid2, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "https://www.example.com", + title: "example.com", + }); + + await sync_engine_and_validate_telem(engine, false); + // Make sure we set hasDupe on outgoing records + Assert.ok(collection.payloads().every(payload => payload.hasDupe)); + + await PlacesUtils.bookmarks.remove(guid1); + + await sync_engine_and_validate_telem(engine, false); + + let tombstone = JSON.parse( + JSON.parse(collection.payload(guid1)).ciphertext + ); + // We shouldn't set hasDupe on tombstones. + Assert.ok(tombstone.deleted); + Assert.ok(!tombstone.hasDupe); + + let record = JSON.parse(JSON.parse(collection.payload(guid2)).ciphertext); + // We should set hasDupe on weakly uploaded records. + Assert.ok(!record.deleted); + Assert.ok( + record.hasDupe, + "Bookmarks bookmark engine should set hasDupe for weakly uploaded records." + ); + + await sync_engine_and_validate_telem(engine, false); + } finally { + await cleanup(engine, server); + await engine.finalize(); + } +}); + +// Bug 890217. +add_bookmark_test(async function test_sync_imap_URLs(engine) { + await Service.recordManager.clearCache(); + await PlacesSyncUtils.bookmarks.reset(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + + engine._tracker.start(); // We skip usual startup... + + try { + collection.insert( + "menu", + encryptPayload({ + id: "menu", + type: "folder", + parentid: "places", + title: "Bookmarks Menu", + children: ["bookmarkAAAA"], + }) + ); + collection.insert( + "bookmarkAAAA", + encryptPayload({ + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + bmkUri: + "imap://vs@eleven.vs.solnicky.cz:993/fetch%3EUID%3E/" + + "INBOX%3E56291?part=1.2&type=image/jpeg&filename=" + + "invalidazPrahy.jpg", + title: + "invalidazPrahy.jpg (JPEG Image, 1280x1024 pixels) - Scaled (71%)", + }) + ); + + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkBBBB", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: + "imap://eleven.vs.solnicky.cz:993/fetch%3EUID%3E/" + + "CURRENT%3E2433?part=1.2&type=text/html&filename=TomEdwards.html", + title: "TomEdwards.html", + }); + + await sync_engine_and_validate_telem(engine, false); + + let aInfo = await PlacesUtils.bookmarks.fetch("bookmarkAAAA"); + equal( + aInfo.url.href, + "imap://vs@eleven.vs.solnicky.cz:993/" + + "fetch%3EUID%3E/INBOX%3E56291?part=1.2&type=image/jpeg&filename=" + + "invalidazPrahy.jpg", + "Remote bookmark A with IMAP URL should exist locally" + ); + + let bPayload = collection.cleartext("bookmarkBBBB"); + equal( + bPayload.bmkUri, + "imap://eleven.vs.solnicky.cz:993/" + + "fetch%3EUID%3E/CURRENT%3E2433?part=1.2&type=text/html&filename=" + + "TomEdwards.html", + "Local bookmark B with IMAP URL should exist remotely" + ); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_resume_buffer() { + await Service.recordManager.clearCache(); + let engine = new BookmarksEngine(Service); + await engine.initialize(); + await engine._store.wipe(); + await engine.resetClient(); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + + engine._tracker.start(); // We skip usual startup... + + const batchChunkSize = 50; + + engine._store._batchChunkSize = batchChunkSize; + try { + let children = []; + + let timestamp = round_timestamp(Date.now()); + // Add two chunks worth of records to the server + for (let i = 0; i < batchChunkSize * 2; ++i) { + let cleartext = { + id: Utils.makeGUID(), + type: "bookmark", + parentid: "toolbar", + title: `Bookmark ${i}`, + parentName: "Bookmarks Toolbar", + bmkUri: `https://example.com/${i}`, + }; + let wbo = collection.insert( + cleartext.id, + encryptPayload(cleartext), + timestamp + 10 * i + ); + // Something that is effectively random, but deterministic. + // (This is just to ensure we don't accidentally start using the + // sortindex again). + wbo.sortindex = 1000 + Math.round(Math.sin(i / 5) * 100); + children.push(cleartext.id); + } + + // Add the parent of those records, and ensure its timestamp is the most recent. + collection.insert( + "toolbar", + encryptPayload({ + id: "toolbar", + type: "folder", + parentid: "places", + title: "Bookmarks Toolbar", + children, + }), + timestamp + 10 * children.length + ); + + // Replace applyIncomingBatch with a custom one that calls the original, + // but forces it to throw on the 2nd chunk. + let origApplyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = function (records) { + if (records.length > batchChunkSize) { + // Hacky way to make reading from the batchChunkSize'th record throw. + delete records[batchChunkSize]; + Object.defineProperty(records, batchChunkSize, { + get() { + throw new Error("D:"); + }, + }); + } + return origApplyIncomingBatch.call(this, records); + }; + + let caughtError; + _("We expect this to fail"); + try { + await sync_engine_and_validate_telem(engine, true); + } catch (e) { + caughtError = e; + } + Assert.ok(caughtError, "Expected engine.sync to throw"); + Assert.equal(caughtError.message, "D:"); + + // The buffer subtracts one second from the actual timestamp. + let lastSync = (await engine.getLastSync()) + 1; + // We poisoned the batchChunkSize'th record, so the last successfully + // applied record will be batchChunkSize - 1. + let expectedLastSync = timestamp + 10 * (batchChunkSize - 1); + Assert.equal(expectedLastSync, lastSync); + + engine._store.applyIncomingBatch = origApplyIncomingBatch; + + await sync_engine_and_validate_telem(engine, false); + + // Check that all the children made it onto the correct record. + let toolbarRecord = await engine._store.createRecord("toolbar"); + Assert.deepEqual(toolbarRecord.children.sort(), children.sort()); + } finally { + await cleanup(engine, server); + await engine.finalize(); + } +}); + +add_bookmark_test(async function test_livemarks(engine) { + _("Ensure we replace new and existing livemarks with tombstones"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("bookmarks"); + let now = Date.now(); + + try { + _("Insert existing livemark"); + let modifiedForA = now - 5 * 60 * 1000; + await PlacesUtils.bookmarks.insert({ + guid: "livemarkAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "A", + lastModified: new Date(modifiedForA), + dateAdded: new Date(modifiedForA), + source: PlacesUtils.bookmarks.SOURCE_SYNC, + }); + collection.insert( + "menu", + encryptPayload({ + id: "menu", + type: "folder", + parentName: "", + title: "menu", + children: ["livemarkAAAA"], + parentid: "places", + }), + round_timestamp(modifiedForA) + ); + collection.insert( + "livemarkAAAA", + encryptPayload({ + id: "livemarkAAAA", + type: "livemark", + feedUri: "http://example.com/a", + parentName: "menu", + title: "A", + parentid: "menu", + }), + round_timestamp(modifiedForA) + ); + + _("Insert remotely updated livemark"); + await PlacesUtils.bookmarks.insert({ + guid: "livemarkBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "B", + lastModified: new Date(now), + dateAdded: new Date(now), + }); + collection.insert( + "toolbar", + encryptPayload({ + id: "toolbar", + type: "folder", + parentName: "", + title: "toolbar", + children: ["livemarkBBBB"], + parentid: "places", + }), + round_timestamp(now) + ); + collection.insert( + "livemarkBBBB", + encryptPayload({ + id: "livemarkBBBB", + type: "livemark", + feedUri: "http://example.com/b", + parentName: "toolbar", + title: "B", + parentid: "toolbar", + }), + round_timestamp(now) + ); + + _("Insert new remote livemark"); + collection.insert( + "unfiled", + encryptPayload({ + id: "unfiled", + type: "folder", + parentName: "", + title: "unfiled", + children: ["livemarkCCCC"], + parentid: "places", + }), + round_timestamp(now) + ); + collection.insert( + "livemarkCCCC", + encryptPayload({ + id: "livemarkCCCC", + type: "livemark", + feedUri: "http://example.com/c", + parentName: "unfiled", + title: "C", + parentid: "unfiled", + }), + round_timestamp(now) + ); + + _("Bump last sync time to ignore A"); + await engine.setLastSync(round_timestamp(now) - 60); + + _("Sync"); + await sync_engine_and_validate_telem(engine, false); + + deepEqual( + collection.keys().sort(), + [ + "livemarkAAAA", + "livemarkBBBB", + "livemarkCCCC", + "menu", + "mobile", + "toolbar", + "unfiled", + ], + "Should store original livemark A and tombstones for B and C on server" + ); + + let payloads = collection.payloads(); + + deepEqual( + payloads.find(payload => payload.id == "menu").children, + ["livemarkAAAA"], + "Should keep A in menu" + ); + ok( + !payloads.find(payload => payload.id == "livemarkAAAA").deleted, + "Should not upload tombstone for A" + ); + + deepEqual( + payloads.find(payload => payload.id == "toolbar").children, + [], + "Should remove B from toolbar" + ); + ok( + payloads.find(payload => payload.id == "livemarkBBBB").deleted, + "Should upload tombstone for B" + ); + + deepEqual( + payloads.find(payload => payload.id == "unfiled").children, + [], + "Should remove C from unfiled" + ); + ok( + payloads.find(payload => payload.id == "livemarkCCCC").deleted, + "Should replace C with tombstone" + ); + + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + children: [ + { + guid: "livemarkAAAA", + index: 0, + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "Should keep A and remove B locally" + ); + } finally { + await cleanup(engine, server); + } +}); + +add_bookmark_test(async function test_unknown_fields(engine) { + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("bookmarks"); + try { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Folder 1", + }); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + let bmk2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }); + let toolbar_record = await store.createRecord("toolbar"); + collection.insert("toolbar", encryptPayload(toolbar_record.cleartext)); + + let folder1_record_without_unknown_fields = await store.createRecord( + folder1.guid + ); + collection.insert( + folder1.guid, + encryptPayload(folder1_record_without_unknown_fields.cleartext) + ); + + // First bookmark record has an unknown string field + let bmk1_record = await store.createRecord(bmk1.guid); + console.log("bmk1_record: ", bmk1_record); + bmk1_record.cleartext.unknownStrField = + "an unknown field from another client"; + collection.insert(bmk1.guid, encryptPayload(bmk1_record.cleartext)); + + // Second bookmark record as an unknown object field + let bmk2_record = await store.createRecord(bmk2.guid); + bmk2_record.cleartext.unknownObjField = { + name: "an unknown object from another client", + }; + collection.insert(bmk2.guid, encryptPayload(bmk2_record.cleartext)); + + // Sync the two bookmarks + await sync_engine_and_validate_telem(engine, true); + + // Add a folder could also have an unknown field + let folder1_record = await store.createRecord(folder1.guid); + folder1_record.cleartext.unknownStrField = + "a folder could also have an unknown field!"; + collection.insert(folder1.guid, encryptPayload(folder1_record.cleartext)); + + // sync the new updates + await engine.setLastSync(1); + await sync_engine_and_validate_telem(engine, true); + + let payloads = collection.payloads(); + // Validate the server has the unknown fields at the top level (and now unknownFields) + let server_bmk1 = payloads.find(payload => payload.id == bmk1.guid); + deepEqual( + server_bmk1.unknownStrField, + "an unknown field from another client", + "unknown fields correctly on the record" + ); + Assert.equal(server_bmk1.unknownFields, null); + + // Check that the mirror table has unknown fields + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + ` + SELECT guid, title, unknownFields from items WHERE guid IN + (:bmk1, :bmk2, :folder1)`, + { bmk1: bmk1.guid, bmk2: bmk2.guid, folder1: folder1.guid } + ); + // We should have 3 rows that came from the server + Assert.equal(rows.length, 3); + + // Bookmark 1 - unknown string field + let remote_bmk1 = rows.find( + row => row.getResultByName("guid") == bmk1.guid + ); + Assert.equal(remote_bmk1.getResultByName("title"), "Get Firefox!"); + deepEqual(JSON.parse(remote_bmk1.getResultByName("unknownFields")), { + unknownStrField: "an unknown field from another client", + }); + + // Bookmark 2 - unknown object field + let remote_bmk2 = rows.find( + row => row.getResultByName("guid") == bmk2.guid + ); + Assert.equal(remote_bmk2.getResultByName("title"), "Get Thunderbird!"); + deepEqual(JSON.parse(remote_bmk2.getResultByName("unknownFields")), { + unknownObjField: { + name: "an unknown object from another client", + }, + }); + + // Folder with unknown field + + // check the server still has the unknown field + deepEqual( + payloads.find(payload => payload.id == folder1.guid).unknownStrField, + "a folder could also have an unknown field!", + "Server still has the unknown field" + ); + + let remote_folder = rows.find( + row => row.getResultByName("guid") == folder1.guid + ); + Assert.equal(remote_folder.getResultByName("title"), "Folder 1"); + deepEqual(JSON.parse(remote_folder.getResultByName("unknownFields")), { + unknownStrField: "a folder could also have an unknown field!", + }); + } finally { + await cleanup(engine, server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_order.js b/src/zen/tests/mochitests/sync/test_bookmark_order.js new file mode 100644 index 000000000..fc182b81e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_order.js @@ -0,0 +1,586 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_( + "Making sure after processing incoming bookmarks, they show up in the right order" +); +const { Bookmark, BookmarkFolder } = ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" +); +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +async function serverForFoo(engine) { + await generateNewKeys(Service.collectionKeys); + + let clientsEngine = Service.clientsEngine; + let clientsSyncID = await clientsEngine.resetLocalSyncID(); + let engineSyncID = await engine.resetLocalSyncID(); + return serverForUsers( + { foo: "password" }, + { + meta: { + global: { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { + clients: { + version: clientsEngine.version, + syncID: clientsSyncID, + }, + [engine.name]: { + version: engine.version, + syncID: engineSyncID, + }, + }, + }, + }, + crypto: { + keys: encryptPayload({ + id: "keys", + // Generate a fake default key bundle to avoid resetting the client + // before the first sync. + default: [ + await Weave.Crypto.generateRandomKey(), + await Weave.Crypto.generateRandomKey(), + ], + }), + }, + [engine.name]: {}, + } + ); +} + +async function resolveConflict( + engine, + collection, + timestamp, + buildTree, + message +) { + let guids = { + // These items don't exist on the server. + fx: Utils.makeGUID(), + nightly: Utils.makeGUID(), + support: Utils.makeGUID(), + customize: Utils.makeGUID(), + + // These exist on the server, but in a different order, and `res` + // has completely different children. + res: Utils.makeGUID(), + tb: Utils.makeGUID(), + + // These don't exist locally. + bz: Utils.makeGUID(), + irc: Utils.makeGUID(), + mdn: Utils.makeGUID(), + }; + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: guids.fx, + title: "Get Firefox!", + url: "http://getfirefox.com/", + }, + { + guid: guids.res, + title: "Resources", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + guid: guids.nightly, + title: "Nightly", + url: "https://nightly.mozilla.org/", + }, + { + guid: guids.support, + title: "Support", + url: "https://support.mozilla.org/", + }, + { + guid: guids.customize, + title: "Customize", + url: "https://mozilla.org/firefox/customize/", + }, + ], + }, + { + title: "Get Thunderbird!", + guid: guids.tb, + url: "http://getthunderbird.com/", + }, + ], + }); + + let serverRecords = [ + { + id: "menu", + type: "folder", + title: "Bookmarks Menu", + parentid: "places", + children: [guids.tb, guids.res], + }, + { + id: guids.tb, + type: "bookmark", + parentid: "menu", + bmkUri: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }, + { + id: guids.res, + type: "folder", + parentid: "menu", + title: "Resources", + children: [guids.irc, guids.bz, guids.mdn], + }, + { + id: guids.bz, + type: "bookmark", + parentid: guids.res, + bmkUri: "https://bugzilla.mozilla.org/", + title: "Bugzilla", + }, + { + id: guids.mdn, + type: "bookmark", + parentid: guids.res, + bmkUri: "https://developer.mozilla.org/", + title: "MDN", + }, + { + id: guids.irc, + type: "bookmark", + parentid: guids.res, + bmkUri: "ircs://irc.mozilla.org/nightly", + title: "IRC", + }, + ]; + for (let record of serverRecords) { + collection.insert(record.id, encryptPayload(record), timestamp); + } + + engine.lastModified = collection.timestamp; + await sync_engine_and_validate_telem(engine, false); + + let expectedTree = buildTree(guids); + await assertBookmarksTreeMatches( + PlacesUtils.bookmarks.menuGuid, + expectedTree, + message + ); +} + +async function get_engine() { + return Service.engineManager.get("bookmarks"); +} + +add_task(async function test_local_order_newer() { + let engine = await get_engine(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + let collection = server.user("foo").collection("bookmarks"); + let serverModified = Date.now() / 1000 - 120; + await resolveConflict( + engine, + collection, + serverModified, + guids => [ + { + guid: guids.fx, + index: 0, + }, + { + guid: guids.res, + index: 1, + children: [ + { + guid: guids.nightly, + index: 0, + }, + { + guid: guids.support, + index: 1, + }, + { + guid: guids.customize, + index: 2, + }, + { + guid: guids.irc, + index: 3, + }, + { + guid: guids.bz, + index: 4, + }, + { + guid: guids.mdn, + index: 5, + }, + ], + }, + { + guid: guids.tb, + index: 2, + }, + ], + "Should use local order as base if remote is older" + ); + } finally { + await engine.wipeClient(); + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_remote_order_newer() { + let engine = await get_engine(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + let collection = server.user("foo").collection("bookmarks"); + let serverModified = Date.now() / 1000 + 120; + await resolveConflict( + engine, + collection, + serverModified, + guids => [ + { + guid: guids.tb, + index: 0, + }, + { + guid: guids.res, + index: 1, + children: [ + { + guid: guids.irc, + index: 0, + }, + { + guid: guids.bz, + index: 1, + }, + { + guid: guids.mdn, + index: 2, + }, + { + guid: guids.nightly, + index: 3, + }, + { + guid: guids.support, + index: 4, + }, + { + guid: guids.customize, + index: 5, + }, + ], + }, + { + guid: guids.fx, + index: 2, + }, + ], + "Should use remote order as base if local is older" + ); + } finally { + await engine.wipeClient(); + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_bookmark_order() { + let engine = await get_engine(); + let store = engine._store; + _("Starting with a clean slate of no bookmarks"); + await store.wipe(); + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + // Index 2 is the tags root. (Root indices depend on the order of the + // `CreateRoot` calls in `Database::CreateBookmarkRoots`). + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "clean slate" + ); + + function bookmark(name, parent) { + let bm = new Bookmark("http://weave.server/my-bookmark"); + bm.id = name; + bm.title = name; + bm.bmkUri = "http://uri/"; + bm.parentid = parent || "unfiled"; + bm.tags = []; + return bm; + } + + function folder(name, parent, children) { + let bmFolder = new BookmarkFolder("http://weave.server/my-bookmark-folder"); + bmFolder.id = name; + bmFolder.title = name; + bmFolder.parentid = parent || "unfiled"; + bmFolder.children = children; + return bmFolder; + } + + async function apply(records) { + for (record of records) { + await store.applyIncoming(record); + } + await engine._apply(); + } + let id10 = "10_aaaaaaaaa"; + _("basic add first bookmark"); + await apply([bookmark(id10, "")]); + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [ + { + guid: id10, + index: 0, + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "basic add first bookmark" + ); + let id20 = "20_aaaaaaaaa"; + _("basic append behind 10"); + await apply([bookmark(id20, "")]); + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [ + { + guid: id10, + index: 0, + }, + { + guid: id20, + index: 1, + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "basic append behind 10" + ); + + let id31 = "31_aaaaaaaaa"; + let id30 = "f30_aaaaaaaa"; + _("basic create in folder"); + let b31 = bookmark(id31, id30); + let f30 = folder(id30, "", [id31]); + await apply([b31, f30]); + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [ + { + guid: id10, + index: 0, + }, + { + guid: id20, + index: 1, + }, + { + guid: id30, + index: 2, + children: [ + { + guid: id31, + index: 0, + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "basic create in folder" + ); + + let id41 = "41_aaaaaaaaa"; + let id40 = "f40_aaaaaaaa"; + _("insert missing parent -> append to unfiled"); + await apply([bookmark(id41, id40)]); + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [ + { + guid: id10, + index: 0, + }, + { + guid: id20, + index: 1, + }, + { + guid: id30, + index: 2, + children: [ + { + guid: id31, + index: 0, + }, + ], + }, + { + guid: id41, + index: 3, + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "insert missing parent -> append to unfiled" + ); + + let id42 = "42_aaaaaaaaa"; + + _("insert another missing parent -> append"); + await apply([bookmark(id42, id40)]); + await assertBookmarksTreeMatches( + "", + [ + { + guid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + index: 1, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + index: 3, + children: [ + { + guid: id10, + index: 0, + }, + { + guid: id20, + index: 1, + }, + { + guid: id30, + index: 2, + children: [ + { + guid: id31, + index: 0, + }, + ], + }, + { + guid: id41, + index: 3, + }, + { + guid: id42, + index: 4, + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + index: 4, + }, + ], + "insert another missing parent -> append" + ); + + await engine.wipeClient(); + await Service.startOver(); + await engine.finalize(); +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_places_query_rewriting.js b/src/zen/tests/mochitests/sync/test_bookmark_places_query_rewriting.js new file mode 100644 index 000000000..e8dbbb48b --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_places_query_rewriting.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Rewrite place: URIs."); +const { BookmarkQuery, BookmarkFolder } = ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" +); +// `Service` is used as a global in head_helpers.js. +// eslint-disable-next-line no-unused-vars +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function makeTagRecord(id, uri) { + let tagRecord = new BookmarkQuery("bookmarks", id); + tagRecord.queryId = "MagicTags"; + tagRecord.parentName = "Bookmarks Toolbar"; + tagRecord.bmkUri = uri; + tagRecord.title = "tagtag"; + tagRecord.folderName = "bar"; + tagRecord.parentid = PlacesUtils.bookmarks.toolbarGuid; + return tagRecord; +} + +add_bookmark_test(async function run_test(engine) { + let store = engine._store; + + let toolbar = new BookmarkFolder("bookmarks", "toolbar"); + toolbar.parentid = "places"; + toolbar.children = ["abcdefabcdef"]; + + let uri = "place:folder=499&type=7&queryType=1"; + let tagRecord = makeTagRecord("abcdefabcdef", uri); + + _("Type: " + tagRecord.type); + _("Folder name: " + tagRecord.folderName); + await store.applyIncoming(toolbar); + await store.applyIncoming(tagRecord); + await engine._apply(); + + let insertedRecord = await store.createRecord("abcdefabcdef", "bookmarks"); + Assert.equal(insertedRecord.bmkUri, "place:tag=bar"); + + _("... but not if the type is wrong."); + let wrongTypeURI = "place:folder=499&type=2&queryType=1"; + let wrongTypeRecord = makeTagRecord("fedcbafedcba", wrongTypeURI); + await store.applyIncoming(wrongTypeRecord); + toolbar.children = ["fedcbafedcba"]; + await store.applyIncoming(toolbar); + let expected = wrongTypeURI; + await engine._apply(); + // the mirror appends a special param to these. + expected += "&excludeItems=1"; + + insertedRecord = await store.createRecord("fedcbafedcba", "bookmarks"); + Assert.equal(insertedRecord.bmkUri, expected); +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_record.js b/src/zen/tests/mochitests/sync/test_bookmark_record.js new file mode 100644 index 000000000..9d160ee68 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_record.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Bookmark, BookmarkQuery, PlacesItem } = ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function prepareBookmarkItem(collection, id) { + let b = new Bookmark(collection, id); + b.cleartext.stuff = "my payload here"; + return b; +} + +add_task(async function test_bookmark_record() { + await configureIdentity(); + + await generateNewKeys(Service.collectionKeys); + let keyBundle = Service.identity.syncKeyBundle; + + _("Creating a record"); + + let placesItem = new PlacesItem("bookmarks", "foo", "bookmark"); + let bookmarkItem = prepareBookmarkItem("bookmarks", "foo"); + + _("Checking getTypeObject"); + Assert.equal(placesItem.getTypeObject(placesItem.type), Bookmark); + Assert.equal(bookmarkItem.getTypeObject(bookmarkItem.type), Bookmark); + + await bookmarkItem.encrypt(keyBundle); + _("Ciphertext is " + bookmarkItem.ciphertext); + Assert.notEqual(bookmarkItem.ciphertext, null); + + _("Decrypting the record"); + + let payload = await bookmarkItem.decrypt(keyBundle); + Assert.equal(payload.stuff, "my payload here"); + Assert.equal(bookmarkItem.getTypeObject(bookmarkItem.type), Bookmark); + Assert.notEqual(payload, bookmarkItem.payload); // wrap.data.payload is the encrypted one +}); + +add_task(async function test_query_foldername() { + // Bug 1443388 + let checks = [ + ["foo", "foo"], + ["", undefined], + ]; + for (let [inVal, outVal] of checks) { + let bmk1 = new BookmarkQuery("bookmarks", Utils.makeGUID()); + bmk1.fromSyncBookmark({ + url: Services.io.newURI("https://example.com"), + folder: inVal, + }); + Assert.strictEqual(bmk1.folderName, outVal); + + // other direction + let bmk2 = new BookmarkQuery("bookmarks", Utils.makeGUID()); + bmk2.folderName = inVal; + let record = bmk2.toSyncBookmark(); + Assert.strictEqual(record.folder, outVal); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_store.js b/src/zen/tests/mochitests/sync/test_bookmark_store.js new file mode 100644 index 000000000..44fcc19aa --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_store.js @@ -0,0 +1,486 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Bookmark, BookmarkFolder, BookmarkQuery, PlacesItem } = + ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" + ); +// `Service` is used as a global in head_helpers.js. +// eslint-disable-next-line no-unused-vars +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const BookmarksToolbarTitle = "toolbar"; + +// apply some test records without going via a test server. +async function apply_records(engine, records) { + for (record of records) { + await engine._store.applyIncoming(record); + } + await engine._apply(); +} + +add_bookmark_test(async function test_ignore_specials(engine) { + _("Ensure that we can't delete bookmark roots."); + let store = engine._store; + + // Belt... + let record = new BookmarkFolder("bookmarks", "toolbar", "folder"); + record.deleted = true; + Assert.notEqual( + null, + await PlacesTestUtils.promiseItemId(PlacesUtils.bookmarks.toolbarGuid) + ); + + await apply_records(engine, [record]); + + // Ensure that the toolbar exists. + Assert.notEqual( + null, + await PlacesTestUtils.promiseItemId(PlacesUtils.bookmarks.toolbarGuid) + ); + + await apply_records(engine, [record]); + + Assert.notEqual( + null, + await PlacesTestUtils.promiseItemId(PlacesUtils.bookmarks.toolbarGuid) + ); + await store.wipe(); +}); + +add_bookmark_test(async function test_bookmark_create(engine) { + let store = engine._store; + + try { + _("Ensure the record isn't present yet."); + let item = await PlacesUtils.bookmarks.fetch({ + url: "http://getfirefox.com/", + }); + Assert.equal(null, item); + + _("Let's create a new record."); + let fxrecord = new Bookmark("bookmarks", "get-firefox1"); + fxrecord.bmkUri = "http://getfirefox.com/"; + fxrecord.title = "Get Firefox!"; + fxrecord.tags = ["firefox", "awesome", "browser"]; + fxrecord.keyword = "awesome"; + fxrecord.parentName = BookmarksToolbarTitle; + fxrecord.parentid = "toolbar"; + await apply_records(engine, [fxrecord]); + + _("Verify it has been created correctly."); + item = await PlacesUtils.bookmarks.fetch(fxrecord.id); + Assert.equal(item.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(item.url.href, "http://getfirefox.com/"); + Assert.equal(item.title, fxrecord.title); + Assert.equal(item.parentGuid, PlacesUtils.bookmarks.toolbarGuid); + let keyword = await PlacesUtils.keywords.fetch(fxrecord.keyword); + Assert.equal(keyword.url.href, "http://getfirefox.com/"); + + _( + "Have the store create a new record object. Verify that it has the same data." + ); + let newrecord = await store.createRecord(fxrecord.id); + Assert.ok(newrecord instanceof Bookmark); + for (let property of [ + "type", + "bmkUri", + "title", + "keyword", + "parentName", + "parentid", + ]) { + Assert.equal(newrecord[property], fxrecord[property]); + } + Assert.ok(Utils.deepEquals(newrecord.tags.sort(), fxrecord.tags.sort())); + + _("The calculated sort index is based on frecency data."); + Assert.greaterOrEqual(newrecord.sortindex, 150); + + _("Create a record with some values missing."); + let tbrecord = new Bookmark("bookmarks", "thunderbird1"); + tbrecord.bmkUri = "http://getthunderbird.com/"; + tbrecord.parentName = BookmarksToolbarTitle; + tbrecord.parentid = "toolbar"; + await apply_records(engine, [tbrecord]); + + _("Verify it has been created correctly."); + item = await PlacesUtils.bookmarks.fetch(tbrecord.id); + Assert.equal(item.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(item.url.href, "http://getthunderbird.com/"); + Assert.equal(item.title, ""); + Assert.equal(item.parentGuid, PlacesUtils.bookmarks.toolbarGuid); + keyword = await PlacesUtils.keywords.fetch({ + url: "http://getthunderbird.com/", + }); + Assert.equal(null, keyword); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_bookmark_update(engine) { + let store = engine._store; + + try { + _("Create a bookmark whose values we'll change."); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + await PlacesUtils.keywords.insert({ + url: "http://getfirefox.com/", + keyword: "firefox", + }); + + _("Update the record with some null values."); + let record = await store.createRecord(bmk1.guid); + record.title = null; + record.keyword = null; + record.tags = null; + await apply_records(engine, [record]); + + _("Verify that the values have been cleared."); + let item = await PlacesUtils.bookmarks.fetch(bmk1.guid); + Assert.equal(item.title, ""); + let keyword = await PlacesUtils.keywords.fetch({ + url: "http://getfirefox.com/", + }); + Assert.equal(null, keyword); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_bookmark_createRecord(engine) { + let store = engine._store; + + try { + _("Create a bookmark without a title."); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com/", + }); + + _("Verify that the record is created accordingly."); + let record = await store.createRecord(bmk1.guid); + Assert.equal(record.title, ""); + Assert.equal(record.keyword, null); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_folder_create(engine) { + let store = engine._store; + + try { + _("Create a folder."); + let folder = new BookmarkFolder("bookmarks", "testfolder-1"); + folder.parentName = BookmarksToolbarTitle; + folder.parentid = "toolbar"; + folder.title = "Test Folder"; + await apply_records(engine, [folder]); + + _("Verify it has been created correctly."); + let item = await PlacesUtils.bookmarks.fetch(folder.id); + Assert.equal(item.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(item.title, folder.title); + Assert.equal(item.parentGuid, PlacesUtils.bookmarks.toolbarGuid); + + _( + "Have the store create a new record object. Verify that it has the same data." + ); + let newrecord = await store.createRecord(folder.id); + Assert.ok(newrecord instanceof BookmarkFolder); + for (let property of ["title", "parentName", "parentid"]) { + Assert.equal(newrecord[property], folder[property]); + } + + _("Folders have high sort index to ensure they're synced first."); + Assert.equal(newrecord.sortindex, 1000000); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_folder_createRecord(engine) { + let store = engine._store; + + try { + _("Create a folder."); + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Folder1", + }); + + _("Create two bookmarks in that folder without assigning them GUIDs."); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + let bmk2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }); + + _("Create a record for the folder and verify basic properties."); + let record = await store.createRecord(folder1.guid); + Assert.ok(record instanceof BookmarkFolder); + Assert.equal(record.title, "Folder1"); + Assert.equal(record.parentid, "toolbar"); + Assert.equal(record.parentName, BookmarksToolbarTitle); + + _( + "Verify the folder's children. Ensures that the bookmarks were given GUIDs." + ); + Assert.deepEqual(record.children, [bmk1.guid, bmk2.guid]); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_deleted(engine) { + let store = engine._store; + + try { + _("Create a bookmark that will be deleted."); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + // The engine needs to think we've previously synced it. + await PlacesTestUtils.markBookmarksAsSynced(); + + _("Delete the bookmark through the store."); + let record = new PlacesItem("bookmarks", bmk1.guid); + record.deleted = true; + await apply_records(engine, [record]); + _("Ensure it has been deleted."); + let item = await PlacesUtils.bookmarks.fetch(bmk1.guid); + let newrec = await store.createRecord(bmk1.guid); + Assert.equal(null, item); + Assert.equal(newrec.deleted, true); + _("Verify that the keyword has been cleared."); + let keyword = await PlacesUtils.keywords.fetch({ + url: "http://getfirefox.com/", + }); + Assert.equal(null, keyword); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_move_folder(engine) { + let store = engine._store; + store._childrenToOrder = {}; // *sob* - only needed for legacy. + + try { + _("Create two folders and a bookmark in one of them."); + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Folder1", + }); + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "Folder2", + }); + let bmk = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + // add records to the store that represent the current state. + await apply_records(engine, [ + await store.createRecord(folder1.guid), + await store.createRecord(folder2.guid), + await store.createRecord(bmk.guid), + ]); + + _("Now simulate incoming records reparenting it."); + let bmkRecord = await store.createRecord(bmk.guid); + Assert.equal(bmkRecord.parentid, folder1.guid); + bmkRecord.parentid = folder2.guid; + + let folder1Record = await store.createRecord(folder1.guid); + Assert.deepEqual(folder1Record.children, [bmk.guid]); + folder1Record.children = []; + let folder2Record = await store.createRecord(folder2.guid); + Assert.deepEqual(folder2Record.children, []); + folder2Record.children = [bmk.guid]; + + await apply_records(engine, [bmkRecord, folder1Record, folder2Record]); + + _("Verify the new parent."); + let movedBmk = await PlacesUtils.bookmarks.fetch(bmk.guid); + Assert.equal(movedBmk.parentGuid, folder2.guid); + } finally { + _("Clean up."); + await store.wipe(); + } +}); + +add_bookmark_test(async function test_move_order(engine) { + let store = engine._store; + let tracker = engine._tracker; + + // Make sure the tracker is turned on. + tracker.start(); + try { + _("Create two bookmarks"); + let bmk1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + let bmk2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }); + + _("Verify order."); + let childIds = + await PlacesSyncUtils.bookmarks.fetchChildRecordIds("toolbar"); + Assert.deepEqual(childIds, [bmk1.guid, bmk2.guid]); + let toolbar = await store.createRecord("toolbar"); + Assert.deepEqual(toolbar.children, [bmk1.guid, bmk2.guid]); + + _("Move bookmarks around."); + store._childrenToOrder = {}; + toolbar.children = [bmk2.guid, bmk1.guid]; + await apply_records(engine, [ + toolbar, + await store.createRecord(bmk1.guid), + await store.createRecord(bmk2.guid), + ]); + delete store._childrenToOrder; + + _("Verify new order."); + let newChildIds = + await PlacesSyncUtils.bookmarks.fetchChildRecordIds("toolbar"); + Assert.deepEqual(newChildIds, [bmk2.guid, bmk1.guid]); + } finally { + await tracker.stop(); + _("Clean up."); + await store.wipe(); + } +}); + +// Tests Bug 806460, in which query records arrive with empty folder +// names and missing bookmark URIs. +add_bookmark_test(async function test_empty_query_doesnt_die(engine) { + let record = new BookmarkQuery("bookmarks", "8xoDGqKrXf1P"); + record.folderName = ""; + record.queryId = ""; + record.parentName = "Toolbar"; + record.parentid = "toolbar"; + + // These should not throw. + await apply_records(engine, [record]); + + delete record.folderName; + await apply_records(engine, [record]); +}); + +add_bookmark_test(async function test_calculateIndex_for_invalid_url(engine) { + let store = engine._store; + + let folderIndex = await store._calculateIndex({ + type: "folder", + }); + equal(folderIndex, 1000000, "Should use high sort index for folders"); + + let toolbarIndex = await store._calculateIndex({ + parentid: "toolbar", + }); + equal(toolbarIndex, 150, "Should bump sort index for toolbar bookmarks"); + + let validURLIndex = await store._calculateIndex({ + bmkUri: "http://example.com/a", + }); + greaterOrEqual(validURLIndex, 0, "Should use frecency for index"); + + let invalidURLIndex = await store._calculateIndex({ + bmkUri: "!@#$%", + }); + equal(invalidURLIndex, 0, "Should not throw for invalid URLs"); +}); + +// Test that applying incoming records uses the server's modified timestamp +// for the local lastModified, not the current time. This is important because +// using the current time causes non-deterministic behavior in the merge +// algorithm when comparing local and remote ages. +add_bookmark_test(async function test_incoming_lastModified(engine) { + let store = engine._store; + + // The server's modified timestamp (in seconds). + const serverModified = 1770000000; + + _("Create a folder record with a specific server modified time."); + let folderRecord = new BookmarkFolder("bookmarks", "BBBBBBBBBBBB"); + folderRecord.title = "Test Folder"; + folderRecord.parentName = BookmarksToolbarTitle; + folderRecord.parentid = "toolbar"; + folderRecord.children = ["AAAAAAAAAAAA"]; + folderRecord.modified = serverModified; + + _("Create a bookmark record with a specific server modified time."); + let bmkRecord = new Bookmark("bookmarks", "AAAAAAAAAAAA"); + bmkRecord.bmkUri = "http://example.com/test"; + bmkRecord.title = "Test Bookmark"; + bmkRecord.parentName = "Test Folder"; + bmkRecord.parentid = "BBBBBBBBBBBB"; + bmkRecord.modified = serverModified; + + await apply_records(engine, [folderRecord, bmkRecord]); + + _("Verify the bookmark's lastModified matches the server timestamp."); + let item = await PlacesUtils.bookmarks.fetch({ guid: "AAAAAAAAAAAA" }); + Assert.ok(item, "Bookmark should exist"); + // The server timestamp is in seconds, lastModified is in milliseconds. + Assert.equal( + item.lastModified.getTime(), + serverModified * 1000, + "lastModified should match the server's modified timestamp" + ); + + _("Verify the folder's lastModified also matches."); + let folder = await PlacesUtils.bookmarks.fetch("BBBBBBBBBBBB"); + Assert.ok(folder, "Folder should exist"); + Assert.equal( + folder.lastModified.getTime(), + serverModified * 1000, + "Folder lastModified should match the server's modified timestamp" + ); + + _("Update the bookmark with a newer server timestamp."); + const newerServerModified = 1780000000; + bmkRecord.title = "Updated Test Bookmark"; + bmkRecord.modified = newerServerModified; + await apply_records(engine, [bmkRecord]); + + item = await PlacesUtils.bookmarks.fetch("AAAAAAAAAAAA"); + Assert.equal( + item.lastModified.getTime(), + newerServerModified * 1000, + "lastModified should be updated to the newer server timestamp" + ); + + await store.wipe(); +}); diff --git a/src/zen/tests/mochitests/sync/test_bookmark_tracker.js b/src/zen/tests/mochitests/sync/test_bookmark_tracker.js new file mode 100644 index 000000000..c47b573e7 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bookmark_tracker.js @@ -0,0 +1,1264 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { PlacesTransactions } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesTransactions.sys.mjs" +); + +let engine; +let store; +let tracker; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; + +add_task(async function setup() { + await Service.engineManager.switchAlternatives(); + engine = Service.engineManager.get("bookmarks"); + store = engine._store; + tracker = engine._tracker; +}); + +// Test helpers. +async function verifyTrackerEmpty() { + await PlacesTestUtils.promiseAsyncUpdates(); + let changes = await tracker.getChangedIDs(); + deepEqual(changes, {}); + equal(tracker.score, 0); +} + +async function resetTracker() { + await PlacesTestUtils.markBookmarksAsSynced(); + tracker.resetScore(); +} + +async function cleanup() { + await engine.setLastSync(0); + await store.wipe(); + await resetTracker(); + await tracker.stop(); +} + +// startTracking is a signal that the test wants to notice things that happen +// after this is called (ie, things already tracked should be discarded.) +async function startTracking() { + engine._tracker.start(); + await PlacesTestUtils.markBookmarksAsSynced(); +} + +async function verifyTrackedItems(tracked) { + await PlacesTestUtils.promiseAsyncUpdates(); + let changedIDs = await tracker.getChangedIDs(); + let trackedIDs = new Set(Object.keys(changedIDs)); + for (let guid of tracked) { + ok(guid in changedIDs, `${guid} should be tracked`); + Assert.greater( + changedIDs[guid].modified, + 0, + `${guid} should have a modified time` + ); + Assert.greaterOrEqual( + changedIDs[guid].counter, + -1, + `${guid} should have a change counter` + ); + trackedIDs.delete(guid); + } + equal( + trackedIDs.size, + 0, + `Unhandled tracked IDs: ${JSON.stringify(Array.from(trackedIDs))}` + ); +} + +async function verifyTrackedCount(expected) { + await PlacesTestUtils.promiseAsyncUpdates(); + let changedIDs = await tracker.getChangedIDs(); + do_check_attribute_count(changedIDs, expected); +} + +// A debugging helper that dumps the full bookmarks tree. +// Currently unused, but might come in handy +// eslint-disable-next-line no-unused-vars +async function dumpBookmarks() { + let columns = [ + "id", + "title", + "guid", + "syncStatus", + "syncChangeCounter", + "position", + ]; + return PlacesUtils.promiseDBConnection().then(connection => { + let all = []; + return connection + .executeCached( + `SELECT ${columns.join(", ")} FROM moz_bookmarks;`, + {}, + row => { + let repr = {}; + for (let column of columns) { + repr[column] = row.getResultByName(column); + } + all.push(repr); + } + ) + .then(() => { + dump("All bookmarks:\n"); + dump(JSON.stringify(all, undefined, 2)); + }); + }); +} + +add_task(async function test_tracking() { + _("Test starting and stopping the tracker"); + + // Remove existing tracking information for roots. + await startTracking(); + + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Test Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // creating the folder should have made 2 changes - the folder itself and + // the parent of the folder. + await verifyTrackedCount(2); + // Reset the changes as the rest of the test doesn't want to see these. + await resetTracker(); + + function createBmk() { + return PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + } + + try { + _("Tell the tracker to start tracking changes."); + await startTracking(); + await createBmk(); + // We expect two changed items because the containing folder + // changed as well (new child). + await verifyTrackedCount(2); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + + _("Notifying twice won't do any harm."); + await createBmk(); + await verifyTrackedCount(3); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_tracker_sql_batching() { + _( + "Test tracker does the correct thing when it is forced to batch SQL queries" + ); + + const SQLITE_MAX_VARIABLE_NUMBER = 999; + let numItems = SQLITE_MAX_VARIABLE_NUMBER * 2 + 10; + + await startTracking(); + + let children = []; + for (let i = 0; i < numItems; i++) { + children.push({ + url: "https://example.org/" + i, + title: "Sync Bookmark " + i, + }); + } + let inserted = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + Assert.equal(children.length, numItems); + Assert.equal(inserted.length, numItems + 1); + await verifyTrackedCount(numItems + 2); // The parent and grandparent are also tracked. + await resetTracker(); + + await PlacesUtils.bookmarks.remove(inserted[0]); + await verifyTrackedCount(numItems + 2); + + await cleanup(); +}); + +add_task(async function test_bookmarkAdded() { + _("Items inserted via the synchronous bookmarks API should be tracked"); + + try { + await startTracking(); + + _("Insert a folder using the sync API"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let syncFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Sync Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + await verifyTrackedItems(["menu", syncFolder.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + + await resetTracker(); + await startTracking(); + + _("Insert a bookmark using the sync API"); + totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let syncBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: syncFolder.guid, + url: "https://example.org/sync", + title: "Sync Bookmark", + }); + await verifyTrackedItems([syncFolder.guid, syncBmk.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_bookmarkAdded() { + _("Items inserted via the asynchronous bookmarks API should be tracked"); + + try { + await startTracking(); + + _("Insert a folder using the async API"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let asyncFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Async Folder", + }); + await verifyTrackedItems(["menu", asyncFolder.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + + await resetTracker(); + await startTracking(); + + _("Insert a bookmark using the async API"); + totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let asyncBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: asyncFolder.guid, + url: "https://example.org/async", + title: "Async Bookmark", + }); + await verifyTrackedItems([asyncFolder.guid, asyncBmk.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + + await resetTracker(); + await startTracking(); + + _("Insert a separator using the async API"); + totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let asyncSep = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: asyncFolder.index, + }); + await verifyTrackedItems(["menu", asyncSep.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemChanged() { + _("Items updated using the asynchronous bookmarks API should be tracked"); + + try { + await tracker.stop(); + + _("Insert a bookmark"); + let fxBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fxBmk.guid}`); + + await startTracking(); + + _("Update the bookmark using the async API"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.update({ + guid: fxBmk.guid, + title: "Download Firefox", + url: "https://www.mozilla.org/firefox", + // PlacesUtils.bookmarks.update rejects last modified dates older than + // the added date. + lastModified: new Date(Date.now() + DAY_IN_MS), + }); + + await verifyTrackedItems([fxBmk.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 1); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemChanged_itemDates() { + _("Changes to item dates should be tracked"); + + try { + await tracker.stop(); + + _("Insert a bookmark"); + let fx_bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fx_bm.guid}`); + + await startTracking(); + + _("Reset the bookmark's added date, should not be tracked"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let dateAdded = new Date(Date.now() - DAY_IN_MS); + await PlacesUtils.bookmarks.update({ + guid: fx_bm.guid, + dateAdded, + }); + await verifyTrackedCount(0); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges); + + await resetTracker(); + + _( + "Reset the bookmark's added date and another property, should be tracked" + ); + totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + dateAdded = new Date(); + await PlacesUtils.bookmarks.update({ + guid: fx_bm.guid, + dateAdded, + title: "test", + }); + await verifyTrackedItems([fx_bm.guid]); + Assert.equal(tracker.score, 2 * SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 1); + + await resetTracker(); + + _("Set the bookmark's last modified date"); + totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + let fx_id = await PlacesTestUtils.promiseItemId(fx_bm.guid); + let dateModified = Date.now() * 1000; + PlacesUtils.bookmarks.setItemLastModified(fx_id, dateModified); + await verifyTrackedItems([fx_bm.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 1); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemTagged() { + _("Items tagged using the synchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Create a folder"); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Parent", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + _("Folder ID: " + folder); + _("Folder GUID: " + folder.guid); + + _("Track changes to tags"); + let uri = CommonUtils.makeURI("http://getfirefox.com"); + let b = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: uri, + title: "Get Firefox!", + }); + _("New item is " + b); + _("GUID: " + b.guid); + + await startTracking(); + + _("Tag the item"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + PlacesUtils.tagging.tagURI(uri, ["foo"]); + + // bookmark should be tracked, folder should not be. + await verifyTrackedItems([b.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 6); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemUntagged() { + _("Items untagged using the synchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Insert tagged bookmarks"); + let uri = CommonUtils.makeURI("http://getfirefox.com"); + let fx1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: uri, + title: "Get Firefox!", + }); + // Different parent and title; same URL. + let fx2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: uri, + title: "Download Firefox", + }); + PlacesUtils.tagging.tagURI(uri, ["foo"]); + + await startTracking(); + + _("Remove the tag"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + PlacesUtils.tagging.untagURI(uri, ["foo"]); + + await verifyTrackedItems([fx1.guid, fx2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 4); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 5); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemUntagged() { + _("Items untagged using the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Insert tagged bookmarks"); + let fxBmk1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let fxBmk2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "some tag", + }); + let fxTag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tag.guid, + url: "http://getfirefox.com", + }); + + await startTracking(); + + _("Remove the tag using the async bookmarks API"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.remove(fxTag.guid); + + await verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 4); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 5); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemTagged() { + _("Items tagged using the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Insert untagged bookmarks"); + let folder1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Folder 1", + }); + let fxBmk1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder1.guid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let folder2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Folder 2", + }); + // Different parent and title; same URL. + let fxBmk2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: folder2.guid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + + await startTracking(); + + // This will change once tags are moved into a separate table (bug 424160). + // We specifically test this case because Bookmarks.sys.mjs updates tagged + // bookmarks and notifies observers. + _("Insert a tag using the async bookmarks API"); + let tag = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.tagsGuid, + title: "some tag", + }); + + _("Tag an item using the async bookmarks API"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: tag.guid, + url: "http://getfirefox.com", + }); + + await verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 4); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 5); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemKeywordChanged() { + _("Keyword changes via the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Insert two bookmarks with the same URL"); + let fxBmk1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let fxBmk2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + + await startTracking(); + + _("Add a keyword for both items"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.keywords.insert({ + keyword: "the_keyword", + url: "http://getfirefox.com", + postData: "postData", + }); + + await verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemKeywordDeleted() { + _("Keyword deletions via the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Insert two bookmarks with the same URL and keywords"); + let fxBmk1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let fxBmk2 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com", + title: "Download Firefox", + }); + await PlacesUtils.keywords.insert({ + keyword: "the_keyword", + url: "http://getfirefox.com", + }); + + await startTracking(); + + _("Remove the keyword"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.keywords.remove("the_keyword"); + + await verifyTrackedItems([fxBmk1.guid, fxBmk2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_bookmarkAdded_filtered_root() { + _("Items outside the change roots should not be tracked"); + + try { + await startTracking(); + + _("Create a new root"); + let root = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.rootGuid, + title: "New root", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + _(`New root GUID: ${root.guid}`); + + _("Insert a bookmark underneath the new root"); + let untrackedBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + _(`New untracked bookmark GUID: ${untrackedBmk.guid}`); + + _("Insert a bookmark underneath the Places root"); + let rootBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.rootGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`New Places root bookmark GUID: ${rootBmk.guid}`); + + _("New root and bookmark should be ignored"); + await verifyTrackedItems([]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemDeleted_filtered_root() { + _("Deleted items outside the change roots should not be tracked"); + + try { + await tracker.stop(); + + _("Insert a bookmark underneath the Places root"); + let rootBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.rootGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`New Places root bookmark GUID: ${rootBmk.guid}`); + + await startTracking(); + + await PlacesUtils.bookmarks.remove(rootBmk); + + await verifyTrackedItems([]); + // We'll still increment the counter for the removed item. + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onPageAnnoChanged() { + _("Page annotations should not be tracked"); + + try { + await tracker.stop(); + + _("Insert a bookmark without an annotation"); + let pageURI = "http://getfirefox.com"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: pageURI, + title: "Get Firefox!", + }); + + await startTracking(); + + _("Add a page annotation"); + await PlacesUtils.history.update({ + url: pageURI, + annotations: new Map([[PlacesUtils.CHARSET_ANNO, "UTF-16"]]), + }); + await verifyTrackedItems([]); + Assert.equal(tracker.score, 0); + await resetTracker(); + + _("Remove the page annotation"); + await PlacesUtils.history.update({ + url: pageURI, + annotations: new Map([[PlacesUtils.CHARSET_ANNO, null]]), + }); + await verifyTrackedItems([]); + Assert.equal(tracker.score, 0); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onFaviconChanged() { + _("Favicon changes should not be tracked"); + + try { + await tracker.stop(); + + let pageURI = CommonUtils.makeURI("http://getfirefox.com"); + let iconURI = CommonUtils.makeURI("http://getfirefox.com/icon"); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: pageURI, + title: "Get Firefox!", + }); + + await PlacesTestUtils.addVisits(pageURI); + + await startTracking(); + + _("Favicon annotations should be ignored"); + let iconURL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + await PlacesTestUtils.setFaviconForPage(pageURI, iconURI, iconURL); + await verifyTrackedItems([]); + Assert.equal(tracker.score, 0); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemMoved_moveToFolder() { + _("Items moved via `moveToFolder` should be tracked"); + + try { + await tracker.stop(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkCCCC", + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid: "bookmarkEEEE", + title: "E", + url: "http://example.com/e", + }, + ], + }); + + await startTracking(); + + _("Move (A B D) to the toolbar"); + await PlacesUtils.bookmarks.moveToFolder( + ["bookmarkAAAA", "bookmarkBBBB", "bookmarkDDDD"], + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.DEFAULT_INDEX + ); + + // Moving multiple bookmarks between two folders should track the old + // folder, new folder, and moved bookmarks. + await verifyTrackedItems([ + "menu", + "toolbar", + "bookmarkAAAA", + "bookmarkBBBB", + "bookmarkDDDD", + ]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + await resetTracker(); + + _("Reorder toolbar children: (D A B E)"); + await PlacesUtils.bookmarks.moveToFolder( + ["bookmarkDDDD", "bookmarkAAAA", "bookmarkBBBB"], + PlacesUtils.bookmarks.toolbarGuid, + 0 + ); + + // Reordering bookmarks in a folder should only track the folder, not the + // bookmarks. + await verifyTrackedItems(["toolbar"]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemMoved_update() { + _("Items moved via the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let tbBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + await startTracking(); + + _("Repositioning a bookmark should track the folder"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.update({ + guid: tbBmk.guid, + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + await verifyTrackedItems(["menu"]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 1); + await resetTracker(); + + _("Reparenting a bookmark should track both folders and the bookmark"); + totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.update({ + guid: tbBmk.guid, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + }); + await verifyTrackedItems(["menu", "toolbar", tbBmk.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 3); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemMoved_reorder() { + _("Items reordered via the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + _("Insert out-of-order bookmarks"); + let fxBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fxBmk.guid}`); + + let tbBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + _(`Thunderbird GUID: ${tbBmk.guid}`); + + let mozBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://mozilla.org", + title: "Mozilla", + }); + _(`Mozilla GUID: ${mozBmk.guid}`); + + await startTracking(); + + _("Reorder bookmarks"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.menuGuid, [ + mozBmk.guid, + fxBmk.guid, + tbBmk.guid, + ]); + + // We only track the folder if we reorder its children, but we should + // bump the score for every changed item. + await verifyTrackedItems(["menu"]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 1); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemDeleted_removeFolderTransaction() { + _("Folders removed in a transaction should be tracked"); + + try { + await tracker.stop(); + + _("Create a folder with two children"); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "Test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + _(`Folder GUID: ${folder.guid}`); + let fx = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fx.guid}`); + let tb = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + _(`Thunderbird GUID: ${tb.guid}`); + + await startTracking(); + + let txn = PlacesTransactions.Remove({ guid: folder.guid }); + // We haven't executed the transaction yet. + await verifyTrackerEmpty(); + + _("Execute the remove folder transaction"); + await txn.transact(); + await verifyTrackedItems(["menu", folder.guid, fx.guid, tb.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + await resetTracker(); + + _("Undo the remove folder transaction"); + await PlacesTransactions.undo(); + + await verifyTrackedItems(["menu", folder.guid, fx.guid, tb.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + await resetTracker(); + + _("Redo the transaction"); + await PlacesTransactions.redo(); + await verifyTrackedItems(["menu", folder.guid, fx.guid, tb.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_treeMoved() { + _("Moving an entire tree of bookmarks should track the parents"); + + try { + // Create a couple of parent folders. + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + test: "First test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // A second folder in the first. + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + title: "Second test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // Create a couple of bookmarks in the second folder. + await PlacesUtils.bookmarks.insert({ + parentGuid: folder2.guid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: folder2.guid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + await startTracking(); + + // Move folder 2 to be a sibling of folder1. + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.update({ + guid: folder2.guid, + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + + // the menu and both folders should be tracked, the children should not be. + await verifyTrackedItems(["menu", folder1.guid, folder2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 3); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemDeleted() { + _("Bookmarks deleted via the synchronous API should be tracked"); + + try { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let tb = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + await startTracking(); + + // Delete the last item - the item and parent should be tracked. + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.remove(tb); + + await verifyTrackedItems(["menu", tb.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemDeleted() { + _("Bookmarks deleted via the asynchronous API should be tracked"); + + try { + await tracker.stop(); + + let fxBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + await startTracking(); + + _("Delete the first item"); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.remove(fxBmk.guid); + + await verifyTrackedItems(["menu", fxBmk.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 2); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_async_onItemDeleted_eraseEverything() { + _("Erasing everything should track all deleted items"); + + try { + await tracker.stop(); + + let fxBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + _(`Firefox GUID: ${fxBmk.guid}`); + let tbBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.mobileGuid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + _(`Thunderbird GUID: ${tbBmk.guid}`); + let mozBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://mozilla.org", + title: "Mozilla", + }); + _(`Mozilla GUID: ${mozBmk.guid}`); + let mdnBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://developer.mozilla.org", + title: "MDN", + }); + _(`MDN GUID: ${mdnBmk.guid}`); + let bugsFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bugs", + }); + _(`Bugs folder GUID: ${bugsFolder.guid}`); + let bzBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: bugsFolder.guid, + url: "https://bugzilla.mozilla.org", + title: "Bugzilla", + }); + _(`Bugzilla GUID: ${bzBmk.guid}`); + let bugsChildFolder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: bugsFolder.guid, + title: "Bugs child", + }); + _(`Bugs child GUID: ${bugsChildFolder.guid}`); + let bugsGrandChildBmk = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: bugsChildFolder.guid, + url: "https://example.com", + title: "Bugs grandchild", + }); + _(`Bugs grandchild GUID: ${bugsGrandChildBmk.guid}`); + + await startTracking(); + // Simulate moving a synced item into a new folder. Deleting the folder + // should write a tombstone for the item, but not the folder. + await PlacesTestUtils.setBookmarkSyncFields({ + guid: bugsChildFolder.guid, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NEW, + }); + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.eraseEverything(); + + // bugsChildFolder's sync status is still "NEW", so it shouldn't be + // tracked. bugsGrandChildBmk is "NORMAL", so we *should* write a + // tombstone and track it. + await verifyTrackedItems([ + "menu", + mozBmk.guid, + mdnBmk.guid, + "toolbar", + bugsFolder.guid, + "mobile", + fxBmk.guid, + tbBmk.guid, + "unfiled", + bzBmk.guid, + bugsGrandChildBmk.guid, + ]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 8); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 11); + } finally { + _("Clean up."); + await cleanup(); + } +}); + +add_task(async function test_onItemDeleted_tree() { + _("Deleting a tree of bookmarks should track all items"); + + try { + // Create a couple of parent folders. + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "First test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // A second folder in the first. + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + title: "Second test folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + // Create a couple of bookmarks in the second folder. + let fx = await PlacesUtils.bookmarks.insert({ + parentGuid: folder2.guid, + url: "http://getfirefox.com", + title: "Get Firefox!", + }); + let tb = await PlacesUtils.bookmarks.insert({ + parentGuid: folder2.guid, + url: "http://getthunderbird.com", + title: "Get Thunderbird!", + }); + + await startTracking(); + + // Delete folder2 - everything we created should be tracked. + let totalSyncChanges = PlacesUtils.bookmarks.totalSyncChanges; + await PlacesUtils.bookmarks.remove(folder2); + + await verifyTrackedItems([fx.guid, tb.guid, folder1.guid, folder2.guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(PlacesUtils.bookmarks.totalSyncChanges, totalSyncChanges + 4); + } finally { + _("Clean up."); + await cleanup(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_bridged_engine.js b/src/zen/tests/mochitests/sync/test_bridged_engine.js new file mode 100644 index 000000000..f0c49977b --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_bridged_engine.js @@ -0,0 +1,230 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { BridgedEngine } = ChromeUtils.importESModule( + "resource://services-sync/bridged_engine.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function test_interface() { + class TestBridge { + constructor() { + this.storageVersion = 2; + this.syncID = "syncID111111"; + this.clear(); + } + + clear() { + this.lastSyncMillis = 0; + this.wasSyncStarted = false; + this.incomingEnvelopes = []; + this.uploadedIDs = []; + this.wasSyncFinished = false; + this.wasReset = false; + this.wasWiped = false; + } + + // `mozIBridgedSyncEngine` methods. + + lastSync() { + return this.lastSyncMillis; + } + + setLastSync(millis) { + this.lastSyncMillis = millis; + } + + resetSyncId() { + return this.syncID; + } + + ensureCurrentSyncId(newSyncId) { + equal(newSyncId, this.syncID, "Local and new sync IDs should match"); + return this.syncID; + } + + syncStarted() { + this.wasSyncStarted = true; + } + + storeIncoming(envelopes) { + this.incomingEnvelopes.push(...envelopes.map(r => JSON.parse(r))); + } + + apply() { + let outgoingEnvelopes = [ + { + id: "hanson", + data: { + plants: ["seed", "flower 💐", "rose"], + canYouTell: false, + }, + }, + { + id: "sheryl-crow", + data: { + today: "winding 🛣", + tomorrow: "winding 🛣", + }, + }, + ].map(cleartext => + JSON.stringify({ + id: cleartext.id, + payload: JSON.stringify(cleartext), + }) + ); + return outgoingEnvelopes; + } + + setUploaded(millis, ids) { + this.uploadedIDs.push(...ids); + } + + syncFinished() { + this.wasSyncFinished = true; + } + + reset() { + this.clear(); + this.wasReset = true; + } + + wipe() { + this.clear(); + this.wasWiped = true; + } + } + + let bridge = new TestBridge(); + let engine = new BridgedEngine("Nineties", Service); + engine._bridge = bridge; + engine.enabled = true; + + let server = await serverForFoo(engine); + try { + await SyncTestingInfrastructure(server); + + info("Add server records"); + let foo = server.user("foo"); + let collection = foo.collection("nineties"); + let now = new_timestamp(); + collection.insert( + "backstreet", + encryptPayload({ + id: "backstreet", + data: { + say: "I want it that way", + when: "never", + }, + }), + now + ); + collection.insert( + "tlc", + encryptPayload({ + id: "tlc", + data: { + forbidden: ["scrubs 🚫"], + numberAvailable: false, + }, + }), + now + 5 + ); + + info("Sync the engine"); + // Advance the last sync time to skip the Backstreet Boys... + bridge.lastSyncMillis = 1000 * (now + 2); + await sync_engine_and_validate_telem(engine, false); + + let metaGlobal = foo.collection("meta").wbo("global").get(); + deepEqual( + JSON.parse(metaGlobal.payload).engines.nineties, + { + version: 2, + syncID: "syncID111111", + }, + "Should write storage version and sync ID to m/g" + ); + + greater(bridge.lastSyncMillis, 0, "Should update last sync time"); + ok( + bridge.wasSyncStarted, + "Should have started sync before storing incoming" + ); + deepEqual( + bridge.incomingEnvelopes + .sort((a, b) => a.id.localeCompare(b.id)) + .map(({ payload, ...envelope }) => ({ + cleartextAsObject: JSON.parse(payload), + ...envelope, + })), + [ + { + id: "tlc", + modified: now + 5, + cleartextAsObject: { + id: "tlc", + data: { + forbidden: ["scrubs 🚫"], + numberAvailable: false, + }, + }, + }, + ], + "Should stage incoming records from server" + ); + deepEqual( + bridge.uploadedIDs.sort(), + ["hanson", "sheryl-crow"], + "Should mark new local records as uploaded" + ); + ok(bridge.wasSyncFinished, "Should have finished sync after uploading"); + + deepEqual( + collection.keys().sort(), + ["backstreet", "hanson", "sheryl-crow", "tlc"], + "Should have all records on server" + ); + let expectedRecords = [ + { + id: "sheryl-crow", + data: { + today: "winding 🛣", + tomorrow: "winding 🛣", + }, + }, + { + id: "hanson", + data: { + plants: ["seed", "flower 💐", "rose"], + canYouTell: false, + }, + }, + ]; + for (let expected of expectedRecords) { + let actual = collection.cleartext(expected.id); + deepEqual( + actual, + expected, + `Should upload record ${expected.id} from bridged engine` + ); + } + + await engine.resetClient(); + ok(bridge.wasReset, "Should reset local storage for bridge"); + + await engine.wipeClient(); + ok(bridge.wasWiped, "Should wipe local storage for bridge"); + + await engine.resetSyncID(); + ok( + !foo.collection("nineties"), + "Should delete server collection after resetting sync ID" + ); + } finally { + await promiseStopServer(server); + await engine.finalize(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_clients_engine.js b/src/zen/tests/mochitests/sync/test_clients_engine.js new file mode 100644 index 000000000..631819072 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_clients_engine.js @@ -0,0 +1,2129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { ClientEngine, ClientsRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/clients.sys.mjs" +); +const { CryptoWrapper } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days +const LESS_THAN_CLIENTS_TTL_REFRESH = 86400; // 1 day + +let engine; + +/** + * Unpack the record with this ID, and verify that it has the same version that + * we should be putting into records. + */ +async function check_record_version(user, id) { + let payload = user.collection("clients").wbo(id).data; + + let rec = new CryptoWrapper(); + rec.id = id; + rec.collection = "clients"; + rec.ciphertext = payload.ciphertext; + rec.hmac = payload.hmac; + rec.IV = payload.IV; + + let cleartext = await rec.decrypt( + Service.collectionKeys.keyForCollection("clients") + ); + + _("Payload is " + JSON.stringify(cleartext)); + equal(Services.appinfo.version, cleartext.version); + equal(1, cleartext.protocols.length); + equal("1.5", cleartext.protocols[0]); +} + +// compare 2 different command arrays, taking into account that a flowID +// attribute must exist, be unique in the commands, but isn't specified in +// "expected" as the value isn't known. +function compareCommands(actual, expected, description) { + let tweakedActual = JSON.parse(JSON.stringify(actual)); + tweakedActual.map(elt => delete elt.flowID); + deepEqual(tweakedActual, expected, description); + // each item must have a unique flowID. + let allIDs = new Set(actual.map(elt => elt.flowID).filter(fid => !!fid)); + equal(allIDs.size, actual.length, "all items have unique IDs"); +} + +async function syncClientsEngine(server) { + engine._lastFxADevicesFetch = 0; + engine.lastModified = server.getCollection("foo", "clients").timestamp; + await engine._sync(); +} + +add_setup(async function () { + engine = Service.clientsEngine; + + do_get_profile(); // FOG requires a profile directory. + Services.fog.initializeFOG(); +}); + +async function cleanup() { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await engine._tracker.clearChangedIDs(); + await engine._resetClient(); + // un-cleanup the logs (the resetBranch will have reset their levels), since + // not all the tests use SyncTestingInfrastructure, and it's cheap. + syncTestLogging(); + // We don't finalize storage at cleanup, since we use the same clients engine + // instance across all tests. +} + +add_task(async function test_bad_hmac() { + _("Ensure that Clients engine deletes corrupt records."); + let deletedCollections = []; + let deletedItems = []; + let callback = { + onItemDeleted(username, coll, wboID) { + deletedItems.push(coll + "/" + wboID); + }, + onCollectionDeleted(username, coll) { + deletedCollections.push(coll); + }, + }; + Object.setPrototypeOf(callback, SyncServerCallback); + let server = await serverForFoo(engine, callback); + let user = server.user("foo"); + + function check_clients_count(expectedCount) { + let coll = user.collection("clients"); + + // Treat a non-existent collection as empty. + equal(expectedCount, coll ? coll.count() : 0); + } + + function check_client_deleted(id) { + let coll = user.collection("clients"); + let wbo = coll.wbo(id); + return !wbo || !wbo.payload; + } + + async function uploadNewKeys() { + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + ok( + (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success + ); + } + + try { + await configureIdentity({ username: "foo" }, server); + await Service.login(); + + await generateNewKeys(Service.collectionKeys); + + _("First sync, client record is uploaded"); + equal(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + check_clients_count(0); + await syncClientsEngine(server); + check_clients_count(1); + Assert.greater(engine.lastRecordUpload, 0); + ok(!engine.isFirstSync); + + // Our uploaded record has a version. + await check_record_version(user, engine.localID); + + // Initial setup can wipe the server, so clean up. + deletedCollections = []; + deletedItems = []; + + _("Change our keys and our client ID, reupload keys."); + let oldLocalID = engine.localID; // Preserve to test for deletion! + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + ok( + (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success + ); + + _("Sync."); + await syncClientsEngine(server); + + _("Old record " + oldLocalID + " was deleted, new one uploaded."); + check_clients_count(1); + check_client_deleted(oldLocalID); + + _( + "Now change our keys but don't upload them. " + + "That means we get an HMAC error but redownload keys." + ); + Service.lastHMACEvent = 0; + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + await generateNewKeys(Service.collectionKeys); + deletedCollections = []; + deletedItems = []; + check_clients_count(1); + await syncClientsEngine(server); + + _("Old record was not deleted, new one uploaded."); + equal(deletedCollections.length, 0); + equal(deletedItems.length, 0); + check_clients_count(2); + + _( + "Now try the scenario where our keys are wrong *and* there's a bad record." + ); + // Clean up and start fresh. + user.collection("clients")._wbos = {}; + Service.lastHMACEvent = 0; + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + deletedCollections = []; + deletedItems = []; + check_clients_count(0); + + await uploadNewKeys(); + + // Sync once to upload a record. + await syncClientsEngine(server); + check_clients_count(1); + + // Generate and upload new keys, so the old client record is wrong. + await uploadNewKeys(); + + // Create a new client record and new keys. Now our keys are wrong, as well + // as the object on the server. We'll download the new keys and also delete + // the bad client record. + oldLocalID = engine.localID; // Preserve to test for deletion! + engine.localID = Utils.makeGUID(); + await engine.resetClient(); + await generateNewKeys(Service.collectionKeys); + let oldKey = Service.collectionKeys.keyForCollection(); + + equal(deletedCollections.length, 0); + equal(deletedItems.length, 0); + await syncClientsEngine(server); + equal(deletedItems.length, 1); + check_client_deleted(oldLocalID); + check_clients_count(1); + let newKey = Service.collectionKeys.keyForCollection(); + ok(!oldKey.equals(newKey)); + } finally { + await cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_properties() { + _("Test lastRecordUpload property"); + try { + equal( + Svc.PrefBranch.getPrefType("clients.lastRecordUpload"), + Ci.nsIPrefBranch.PREF_INVALID + ); + equal(engine.lastRecordUpload, 0); + + let now = Date.now(); + engine.lastRecordUpload = now / 1000; + equal(engine.lastRecordUpload, Math.floor(now / 1000)); + } finally { + await cleanup(); + } +}); + +add_task(async function test_full_sync() { + _("Ensure that Clients engine fetches all records for each sync."); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let activeID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + let deletedID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: deletedID, + name: "Client to delete", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + let store = engine._store; + + _("First sync. 2 records downloaded; our record uploaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + Assert.greater(engine.lastRecordUpload, 0); + ok(!engine.isFirstSync); + deepEqual( + user.collection("clients").keys().sort(), + [activeID, deletedID, engine.localID].sort(), + "Our record should be uploaded on first sync" + ); + let ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [activeID, deletedID, engine.localID].sort(), + "Other clients should be downloaded on first sync" + ); + + _("Delete a record, then sync again"); + let collection = server.getCollection("foo", "clients"); + collection.remove(deletedID); + // Simulate a timestamp update in info/collections. + await syncClientsEngine(server); + + _("Record should be updated"); + ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [activeID, engine.localID].sort(), + "Deleted client should be removed on next sync" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_sync() { + _("Ensure that Clients engine uploads a new client record once a week."); + + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + function clientWBO() { + return user.collection("clients").wbo(engine.localID); + } + + try { + _("First sync. Client record is uploaded."); + equal(clientWBO(), undefined); + equal(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + ok(!!clientWBO().payload); + Assert.greater(engine.lastRecordUpload, 0); + ok(!engine.isFirstSync); + + _( + "Let's time travel more than a week back, new record should've been uploaded." + ); + engine.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH; + let lastweek = engine.lastRecordUpload; + clientWBO().payload = undefined; + await syncClientsEngine(server); + ok(!!clientWBO().payload); + Assert.greater(engine.lastRecordUpload, lastweek); + ok(!engine.isFirstSync); + + _("Remove client record."); + await engine.removeClientData(); + equal(clientWBO().payload, undefined); + + _("Time travel one day back, no record uploaded."); + engine.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH; + let yesterday = engine.lastRecordUpload; + await syncClientsEngine(server); + equal(clientWBO().payload, undefined); + equal(engine.lastRecordUpload, yesterday); + ok(!engine.isFirstSync); + } finally { + await cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_client_name_change() { + _("Ensure client name change incurs a client record update."); + + let tracker = engine._tracker; + + engine.localID; // Needed to increase the tracker changedIDs count. + let initialName = engine.localName; + + tracker.start(); + _("initial name: " + initialName); + + // Tracker already has data, so clear it. + await tracker.clearChangedIDs(); + + let initialScore = tracker.score; + + let changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 0); + + Services.prefs.setStringPref( + "identity.fxaccounts.account.device.name", + "new name" + ); + await tracker.asyncObserver.promiseObserversComplete(); + + _("new name: " + engine.localName); + notEqual(initialName, engine.localName); + changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 1); + ok(engine.localID in changedIDs); + Assert.greater(tracker.score, initialScore); + Assert.greaterOrEqual(tracker.score, SCORE_INCREMENT_XLARGE); + + await tracker.stop(); + + await cleanup(); +}); + +add_task(async function test_fxa_device_id_change() { + _("Ensure an FxA device ID change incurs a client record update."); + + let tracker = engine._tracker; + + engine.localID; // Needed to increase the tracker changedIDs count. + + tracker.start(); + + // Tracker already has data, so clear it. + await tracker.clearChangedIDs(); + + let initialScore = tracker.score; + + let changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 0); + + Services.obs.notifyObservers(null, "fxaccounts:new_device_id"); + await tracker.asyncObserver.promiseObserversComplete(); + + changedIDs = await tracker.getChangedIDs(); + equal(Object.keys(changedIDs).length, 1); + ok(engine.localID in changedIDs); + Assert.greater(tracker.score, initialScore); + Assert.greaterOrEqual(tracker.score, SINGLE_USER_THRESHOLD); + + await tracker.stop(); + + await cleanup(); +}); + +add_task(async function test_last_modified() { + _("Ensure that remote records have a sane serverLastModified attribute."); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let activeID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + let collection = user.collection("clients"); + + _("Sync to download the record"); + await syncClientsEngine(server); + + equal( + engine._store._remoteClients[activeID].serverLastModified, + now - 10, + "last modified in the local record is correctly the server last-modified" + ); + + _("Modify the record and re-upload it"); + // set a new name to make sure we really did upload. + engine._store._remoteClients[activeID].name = "New name"; + engine._modified.set(activeID, 0); + // The sync above also did a POST, so adjust our lastModified. + engine.lastModified = server.getCollection("foo", "clients").timestamp; + await engine._uploadOutgoing(); + + _("Local record should have updated timestamp"); + Assert.greaterOrEqual( + engine._store._remoteClients[activeID].serverLastModified, + now + ); + + _("Record on the server should have new name but not serverLastModified"); + let payload = collection.cleartext(activeID); + equal(payload.name, "New name"); + equal(payload.serverLastModified, undefined); + } finally { + await cleanup(); + server.deleteCollections("foo"); + await promiseStopServer(server); + } +}); + +add_task(async function test_send_command() { + _("Verifies _sendCommandToClient puts commands in the outbound queue."); + + let store = engine._store; + let tracker = engine._tracker; + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + + await store.create(rec); + await store.createRecord(remoteId, "clients"); + + let action = "testCommand"; + let args = ["foo", "bar"]; + let extra = { flowID: "flowy" }; + + await engine._sendCommandToClient(action, args, remoteId, extra); + + let newRecord = store._remoteClients[remoteId]; + let clientCommands = (await engine._readCommands())[remoteId]; + notEqual(newRecord, undefined); + equal(clientCommands.length, 1); + + let command = clientCommands[0]; + equal(command.command, action); + equal(command.args.length, 2); + deepEqual(command.args, args); + ok(command.flowID); + + const changes = await tracker.getChangedIDs(); + notEqual(changes[remoteId], undefined); + + await cleanup(); +}); + +// The browser UI might call _addClientCommand indirectly without awaiting on the returned promise. +// We need to make sure this doesn't result on commands not being saved. +add_task(async function test_add_client_command_race() { + let promises = []; + for (let i = 0; i < 100; i++) { + promises.push( + engine._addClientCommand(`client-${i}`, { command: "cmd", args: [] }) + ); + } + await Promise.all(promises); + + let localCommands = await engine._readCommands(); + for (let i = 0; i < 100; i++) { + equal(localCommands[`client-${i}`].length, 1); + } +}); + +add_task(async function test_command_validation() { + _("Verifies that command validation works properly."); + + let store = engine._store; + + let testCommands = [ + ["resetAll", [], true], + ["resetAll", ["foo"], false], + ["resetEngine", ["tabs"], true], + ["resetEngine", [], false], + ["wipeEngine", ["tabs"], true], + ["wipeEngine", [], false], + ["logout", [], true], + ["logout", ["foo"], false], + ["__UNKNOWN__", [], false], + ]; + + for (let [action, args, expectedResult] of testCommands) { + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + + await store.create(rec); + await store.createRecord(remoteId, "clients"); + + await engine.sendCommand(action, args, remoteId); + + let newRecord = store._remoteClients[remoteId]; + notEqual(newRecord, undefined); + + let clientCommands = (await engine._readCommands())[remoteId]; + + if (expectedResult) { + _("Ensuring command is sent: " + action); + equal(clientCommands.length, 1); + + let command = clientCommands[0]; + equal(command.command, action); + deepEqual(command.args, args); + + notEqual(engine._tracker, undefined); + const changes = await engine._tracker.getChangedIDs(); + notEqual(changes[remoteId], undefined); + } else { + _("Ensuring command is scrubbed: " + action); + equal(clientCommands, undefined); + + if (store._tracker) { + equal(engine._tracker[remoteId], undefined); + } + } + } + await cleanup(); +}); + +add_task(async function test_command_duplication() { + _("Ensures duplicate commands are detected and not added"); + + let store = engine._store; + let remoteId = Utils.makeGUID(); + let rec = new ClientsRec("clients", remoteId); + await store.create(rec); + await store.createRecord(remoteId, "clients"); + + let action = "resetAll"; + let args = []; + + await engine.sendCommand(action, args, remoteId); + await engine.sendCommand(action, args, remoteId); + + let clientCommands = (await engine._readCommands())[remoteId]; + equal(clientCommands.length, 1); + + _("Check variant args length"); + await engine._saveCommands({}); + + action = "resetEngine"; + await engine.sendCommand(action, [{ x: "foo" }], remoteId); + await engine.sendCommand(action, [{ x: "bar" }], remoteId); + + _("Make sure we spot a real dupe argument."); + await engine.sendCommand(action, [{ x: "bar" }], remoteId); + + clientCommands = (await engine._readCommands())[remoteId]; + equal(clientCommands.length, 2); + + await cleanup(); +}); + +add_task(async function test_command_invalid_client() { + _("Ensures invalid client IDs are caught"); + + let id = Utils.makeGUID(); + let error; + + try { + await engine.sendCommand("wipeEngine", ["tabs"], id); + } catch (ex) { + error = ex; + } + + equal(error.message.indexOf("Unknown remote client ID: "), 0); + + await cleanup(); +}); + +add_task(async function test_process_incoming_commands() { + _("Ensures local commands are executed"); + + engine.localCommands = [{ command: "logout", args: [] }]; + + let ev = "weave:service:logout:finish"; + + let logoutPromise = new Promise(resolve => { + var handler = function () { + Svc.Obs.remove(ev, handler); + + resolve(); + }; + + Svc.Obs.add(ev, handler); + }); + + // logout command causes processIncomingCommands to return explicit false. + ok(!(await engine.processIncomingCommands())); + + await logoutPromise; + + await cleanup(); +}); + +add_task(async function test_filter_duplicate_names() { + _( + "Ensure that we exclude clients with identical names that haven't synced in a week." + ); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + let user = server.user("foo"); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + // Synced recently. + let recentID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: recentID, + name: "My Phone", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + // synced recently, but not as recent as the phone. + let tabletID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: tabletID, + name: "My Tablet", + type: "tablet", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 100 + ); + + // Dupe of our client, synced more than 1 week ago. + let dupeID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: dupeID, + name: engine.localName, + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 604820 + ); + + // Synced more than 1 week ago, but not a dupe. + let oldID = Utils.makeGUID(); + user.collection("clients").insertRecord( + { + id: oldID, + name: "My old desktop", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 604820 + ); + + try { + let store = engine._store; + + _("First sync"); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + Assert.greater(engine.lastRecordUpload, 0); + ok(!engine.isFirstSync); + deepEqual( + user.collection("clients").keys().sort(), + [recentID, tabletID, dupeID, oldID, engine.localID].sort(), + "Our record should be uploaded on first sync" + ); + + let ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [recentID, tabletID, dupeID, oldID, engine.localID].sort(), + "Duplicate ID should remain in getAllIDs" + ); + ok( + await engine._store.itemExists(dupeID), + "Dupe ID should be considered as existing for Sync methods." + ); + ok( + !engine.remoteClientExists(dupeID), + "Dupe ID should not be considered as existing for external methods." + ); + + // dupe desktop should not appear in .deviceTypes. + equal(engine.deviceTypes.get("desktop"), 2); + equal(engine.deviceTypes.get("mobile"), 2); + + // dupe desktop should not appear in stats + deepEqual(engine.stats, { + hasMobile: 1, + names: [engine.localName, "My Phone", "My Tablet", "My old desktop"], + numClients: 4, + }); + + ok(engine.remoteClientExists(oldID), "non-dupe ID should exist."); + ok(!engine.remoteClientExists(dupeID), "dupe ID should not exist"); + equal( + engine.remoteClients.length, + 3, + "dupe should not be in remoteClients" + ); + + // Check that a subsequent Sync doesn't report anything as being processed. + let counts; + Svc.Obs.add("weave:engine:sync:applied", function observe(subject) { + Svc.Obs.remove("weave:engine:sync:applied", observe); + counts = subject; + }); + + await syncClientsEngine(server); + equal(counts.applied, 0); // We didn't report applying any records. + equal(counts.reconciled, 5); // We reported reconcilliation for all records + equal(counts.succeeded, 0); + equal(counts.failed, 0); + equal(counts.newFailed, 0); + + _("Broadcast logout to all clients"); + await engine.sendCommand("logout", []); + await syncClientsEngine(server); + + let collection = server.getCollection("foo", "clients"); + let recentPayload = collection.cleartext(recentID); + compareCommands( + recentPayload.commands, + [{ command: "logout", args: [] }], + "Should send commands to the recent client" + ); + + let oldPayload = collection.cleartext(oldID); + compareCommands( + oldPayload.commands, + [{ command: "logout", args: [] }], + "Should send commands to the week-old client" + ); + + let dupePayload = collection.cleartext(dupeID); + deepEqual( + dupePayload.commands, + [], + "Should not send commands to the dupe client" + ); + + _("Update the dupe client's modified time"); + collection.insertRecord( + { + id: dupeID, + name: engine.localName, + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + _("Second sync."); + await syncClientsEngine(server); + + ids = await store.getAllIDs(); + deepEqual( + Object.keys(ids).sort(), + [recentID, tabletID, oldID, dupeID, engine.localID].sort(), + "Stale client synced, so it should no longer be marked as a dupe" + ); + + ok( + engine.remoteClientExists(dupeID), + "Dupe ID should appear as it synced." + ); + + // Recently synced dupe desktop should appear in .deviceTypes. + equal(engine.deviceTypes.get("desktop"), 3); + + // Recently synced dupe desktop should now appear in stats + deepEqual(engine.stats, { + hasMobile: 1, + names: [ + engine.localName, + "My Phone", + "My Tablet", + engine.localName, + "My old desktop", + ], + numClients: 5, + }); + + ok( + engine.remoteClientExists(dupeID), + "recently synced dupe ID should now exist" + ); + equal( + engine.remoteClients.length, + 4, + "recently synced dupe should now be in remoteClients" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_command_sync() { + _("Ensure that commands are synced across clients."); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let user = server.user("foo"); + let remoteId = Utils.makeGUID(); + + function clientWBO(id) { + return user.collection("clients").wbo(id); + } + + _("Create remote client record"); + user.collection("clients").insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + try { + _("Syncing."); + await syncClientsEngine(server); + + _("Checking remote record was downloaded."); + let clientRecord = engine._store._remoteClients[remoteId]; + notEqual(clientRecord, undefined); + equal(clientRecord.commands.length, 0); + + _("Send a command to the remote client."); + await engine.sendCommand("wipeEngine", ["tabs"]); + let clientCommands = (await engine._readCommands())[remoteId]; + equal(clientCommands.length, 1); + await syncClientsEngine(server); + + _("Checking record was uploaded."); + notEqual(clientWBO(engine.localID).payload, undefined); + Assert.greater(engine.lastRecordUpload, 0); + ok(!engine.isFirstSync); + + notEqual(clientWBO(remoteId).payload, undefined); + + Svc.PrefBranch.setStringPref("client.GUID", remoteId); + await engine._resetClient(); + equal(engine.localID, remoteId); + _("Performing sync on resetted client."); + await syncClientsEngine(server); + notEqual(engine.localCommands, undefined); + equal(engine.localCommands.length, 1); + + let command = engine.localCommands[0]; + equal(command.command, "wipeEngine"); + equal(command.args.length, 1); + equal(command.args[0], "tabs"); + } finally { + await cleanup(); + + try { + let collection = server.getCollection("foo", "clients"); + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_clients_not_in_fxa_list() { + _("Ensure that clients not in the FxA devices list are marked as stale."); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + let collection = server.getCollection("foo", "clients"); + + _("Create remote client records"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteId, + protocols: ["1.5"], + }); + + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteId2, + protocols: ["1.5"], + }); + + let fxAccounts = engine.fxAccounts; + engine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [{ id: remoteId }], + refreshDeviceList() { + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + try { + _("Syncing."); + await syncClientsEngine(server); + + ok(!engine._store._remoteClients[remoteId].stale); + ok(engine._store._remoteClients[remoteId2].stale); + } finally { + engine.fxAccounts = fxAccounts; + await cleanup(); + + try { + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_dupe_device_ids() { + _( + "Ensure that we mark devices with duplicate fxaDeviceIds but older lastModified as stale." + ); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + let remoteDeviceId = Utils.makeGUID(); + + let collection = server.getCollection("foo", "clients"); + + _("Create remote client records"); + collection.insertRecord( + { + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteDeviceId, + protocols: ["1.5"], + }, + new_timestamp() - 3 + ); + collection.insertRecord({ + id: remoteId2, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteDeviceId, + protocols: ["1.5"], + }); + + let fxAccounts = engine.fxAccounts; + engine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [{ id: remoteDeviceId }], + refreshDeviceList() { + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + try { + _("Syncing."); + await syncClientsEngine(server); + + ok(engine._store._remoteClients[remoteId].stale); + ok(!engine._store._remoteClients[remoteId2].stale); + } finally { + engine.fxAccounts = fxAccounts; + await cleanup(); + + try { + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_refresh_fxa_device_list() { + _("Ensure we refresh the fxa device list when we expect to."); + + await engine._store.wipe(); + engine._lastFxaDeviceRefresh = 0; + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let numRefreshes = 0; + let now = Date.now(); + let fxAccounts = engine.fxAccounts; + engine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [], + refreshDeviceList() { + numRefreshes += 1; + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return now; + }, + }, + }; + + try { + _("Syncing."); + await syncClientsEngine(server); + Assert.equal(numRefreshes, 1, "first sync should refresh"); + now += 1000; // a second later. + await syncClientsEngine(server); + Assert.equal(numRefreshes, 1, "next sync should not refresh"); + now += 60 * 60 * 2 * 1000; // 2 hours later + await syncClientsEngine(server); + Assert.equal(numRefreshes, 2, "2 hours later should refresh"); + now += 1000; // a second later. + Assert.equal(numRefreshes, 2, "next sync should not refresh"); + } finally { + await cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_optional_client_fields() { + _("Ensure that we produce records with the fields added in Bug 1097222."); + + const SUPPORTED_PROTOCOL_VERSIONS = ["1.5"]; + let local = await engine._store.createRecord(engine.localID, "clients"); + equal(local.name, engine.localName); + equal(local.type, engine.localType); + equal(local.version, Services.appinfo.version); + deepEqual(local.protocols, SUPPORTED_PROTOCOL_VERSIONS); + + // Optional fields. + // Make sure they're what they ought to be... + equal(local.os, Services.appinfo.OS); + equal(local.appPackage, Services.appinfo.ID); + + // ... and also that they're non-empty. + ok(!!local.os); + ok(!!local.appPackage); + ok(!!local.application); + + // We don't currently populate device or formfactor. + // See Bug 1100722, Bug 1100723. + + await cleanup(); +}); + +add_task(async function test_merge_commands() { + _("Verifies local commands for remote clients are merged with the server's"); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let desktopID = Utils.makeGUID(); + collection.insertRecord( + { + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + let mobileID = Utils.makeGUID(); + collection.insertRecord( + { + id: mobileID, + name: "Mobile client", + type: "mobile", + commands: [ + { + command: "logout", + args: [], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 2 records downloaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + + _("Broadcast logout to all clients"); + await engine.sendCommand("logout", []); + await syncClientsEngine(server); + + let desktopPayload = collection.cleartext(desktopID); + compareCommands( + desktopPayload.commands, + [ + { + command: "wipeEngine", + args: ["history"], + }, + { + command: "logout", + args: [], + }, + ], + "Should send the logout command to the desktop client" + ); + + let mobilePayload = collection.cleartext(mobileID); + compareCommands( + mobilePayload.commands, + [{ command: "logout", args: [] }], + "Should not send a duplicate logout to the mobile client" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_duplicate_remote_commands() { + _( + "Verifies local commands for remote clients are sent only once (bug 1289287)" + ); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let desktopID = Utils.makeGUID(); + collection.insertRecord( + { + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 1 record downloaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + + _("Send command to client to wipe history engine"); + await engine.sendCommand("wipeEngine", ["history"]); + await syncClientsEngine(server); + + _( + "Simulate the desktop client consuming the command and syncing to the server" + ); + collection.insertRecord( + { + id: desktopID, + name: "Desktop client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + _("Send another command to the desktop client to wipe tabs engine"); + await engine.sendCommand("wipeEngine", ["tabs"], desktopID); + await syncClientsEngine(server); + + let desktopPayload = collection.cleartext(desktopID); + compareCommands( + desktopPayload.commands, + [ + { + command: "wipeEngine", + args: ["tabs"], + }, + ], + "Should only send the second command to the desktop client" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_upload_after_reboot() { + _("Multiple downloads, reboot, then upload (bug 1289287)"); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let deviceBID = Utils.makeGUID(); + let deviceCID = Utils.makeGUID(); + collection.insertRecord( + { + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + collection.insertRecord( + { + id: deviceCID, + name: "Device C", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 2 records downloaded."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + await syncClientsEngine(server); + + _("Send command to client to wipe tab engine"); + await engine.sendCommand("wipeEngine", ["tabs"], deviceBID); + + const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; + SyncEngine.prototype._uploadOutgoing = async () => + engine._onRecordsWritten([], [deviceBID]); + await syncClientsEngine(server); + + let deviceBPayload = collection.cleartext(deviceBID); + compareCommands( + deviceBPayload.commands, + [ + { + command: "wipeEngine", + args: ["history"], + }, + ], + "Should be the same because the upload failed" + ); + + _("Simulate the client B consuming the command and syncing to the server"); + collection.insertRecord( + { + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + // Simulate reboot + SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; + engine = Service.clientsEngine = new ClientEngine(Service); + await engine.initialize(); + + await syncClientsEngine(server); + + deviceBPayload = collection.cleartext(deviceBID); + compareCommands( + deviceBPayload.commands, + [ + { + command: "wipeEngine", + args: ["tabs"], + }, + ], + "Should only had written our outgoing command" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_keep_cleared_commands_after_reboot() { + _( + "Download commands, fail upload, reboot, then apply new commands (bug 1289287)" + ); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let deviceBID = Utils.makeGUID(); + let deviceCID = Utils.makeGUID(); + collection.insertRecord( + { + id: engine.localID, + name: "Device A", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + { + command: "wipeEngine", + args: ["tabs"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + collection.insertRecord( + { + id: deviceBID, + name: "Device B", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + collection.insertRecord( + { + id: deviceCID, + name: "Device C", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. Download remote and our record."); + strictEqual(engine.lastRecordUpload, 0); + ok(engine.isFirstSync); + + const oldUploadOutgoing = SyncEngine.prototype._uploadOutgoing; + SyncEngine.prototype._uploadOutgoing = async () => + engine._onRecordsWritten([], [deviceBID]); + let commandsProcessed = 0; + engine.service.wipeClient = _engine => { + commandsProcessed++; + }; + + await syncClientsEngine(server); + await engine.processIncomingCommands(); // Not called by the engine.sync(), gotta call it ourselves + equal(commandsProcessed, 2, "We processed 2 commands"); + + let localRemoteRecord = collection.cleartext(engine.localID); + compareCommands( + localRemoteRecord.commands, + [ + { + command: "wipeEngine", + args: ["history"], + }, + { + command: "wipeEngine", + args: ["tabs"], + }, + ], + "Should be the same because the upload failed" + ); + + // Another client sends a wipe command + collection.insertRecord( + { + id: engine.localID, + name: "Device A", + type: "desktop", + commands: [ + { + command: "wipeEngine", + args: ["history"], + flowID: Utils.makeGUID(), + }, + { + command: "wipeEngine", + args: ["tabs"], + flowID: Utils.makeGUID(), + }, + { + command: "wipeEngine", + args: ["bookmarks"], + flowID: Utils.makeGUID(), + }, + ], + version: "48", + protocols: ["1.5"], + }, + now - 5 + ); + + // Simulate reboot + SyncEngine.prototype._uploadOutgoing = oldUploadOutgoing; + engine = Service.clientsEngine = new ClientEngine(Service); + await engine.initialize(); + + commandsProcessed = 0; + engine.service.wipeClient = _engine => { + commandsProcessed++; + }; + await syncClientsEngine(server); + await engine.processIncomingCommands(); + equal( + commandsProcessed, + 1, + "We processed one command (the other were cleared)" + ); + + localRemoteRecord = collection.cleartext(deviceBID); + deepEqual(localRemoteRecord.commands, [], "Should be empty"); + } finally { + await cleanup(); + + // Reset service (remove mocks) + engine = Service.clientsEngine = new ClientEngine(Service); + await engine.initialize(); + await engine._resetClient(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_deleted_commands() { + _("Verifies commands for a deleted client are discarded"); + + let now = new_timestamp(); + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let collection = server.getCollection("foo", "clients"); + + let activeID = Utils.makeGUID(); + collection.insertRecord( + { + id: activeID, + name: "Active client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + let deletedID = Utils.makeGUID(); + collection.insertRecord( + { + id: deletedID, + name: "Client to delete", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }, + now - 10 + ); + + try { + _("First sync. 2 records downloaded."); + await syncClientsEngine(server); + + _("Delete a record on the server."); + collection.remove(deletedID); + + _("Broadcast a command to all clients"); + await engine.sendCommand("logout", []); + await syncClientsEngine(server); + + deepEqual( + collection.keys().sort(), + [activeID, engine.localID].sort(), + "Should not reupload deleted clients" + ); + + let activePayload = collection.cleartext(activeID); + compareCommands( + activePayload.commands, + [{ command: "logout", args: [] }], + "Should send the command to the active client" + ); + } finally { + await cleanup(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_command_sync() { + _("Notify other clients when writing their record."); + + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.getCollection("foo", "clients"); + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + _("Create remote client record 1"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + _("Create remote client record 2"); + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + try { + equal(collection.count(), 2, "2 remote records written"); + await syncClientsEngine(server); + equal( + collection.count(), + 3, + "3 remote records written (+1 for the synced local record)" + ); + + await engine.sendCommand("wipeEngine", ["tabs"]); + await engine._tracker.addChangedID(engine.localID); + const getClientFxaDeviceId = sinon + .stub(engine, "getClientFxaDeviceId") + .callsFake(id => "fxa-" + id); + const engineMock = sinon.mock(engine); + let _notifyCollectionChanged = engineMock + .expects("_notifyCollectionChanged") + .withArgs(["fxa-" + remoteId, "fxa-" + remoteId2]); + _("Syncing."); + await syncClientsEngine(server); + _notifyCollectionChanged.verify(); + + engineMock.restore(); + getClientFxaDeviceId.restore(); + } finally { + await cleanup(); + await engine._tracker.clearChangedIDs(); + + try { + server.deleteCollections("foo"); + } finally { + await promiseStopServer(server); + } + } +}); + +add_task(async function ensureSameFlowIDs() { + // Clear events from other test cases. + Services.fog.testResetFOG(); + + let server = await serverForFoo(engine); + try { + // Setup 2 clients, send them a command, and ensure we get to events + // written, both with the same flowID. + await SyncTestingInfrastructure(server); + let collection = server.getCollection("foo", "clients"); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + _("Create remote client record 1"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + _("Create remote client record 2"); + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + await syncClientsEngine(server); + await engine.sendCommand("wipeEngine", ["tabs"]); + await syncClientsEngine(server); + let wipeEvents = Glean.syncClient.sendcommand.testGetValue(); + equal(wipeEvents.length, 2); + equal(wipeEvents[0].extra.flow_id, wipeEvents[1].extra.flow_id); + Services.fog.testResetFOG(); + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + // check it's correctly used when we specify a flow ID + let flowID = Utils.makeGUID(); + await engine.sendCommand("wipeEngine", ["tabs"], null, { flowID }); + await syncClientsEngine(server); + wipeEvents = Glean.syncClient.sendcommand.testGetValue(); + equal(wipeEvents.length, 2); + equal(wipeEvents[0].extra.flow_id, flowID); + equal(wipeEvents[1].extra.flow_id, flowID); + Services.fog.testResetFOG(); + + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + + // and that it works when something else is in "extra" + await engine.sendCommand("wipeEngine", ["tabs"], null, { + reason: "testing", + }); + await syncClientsEngine(server); + wipeEvents = Glean.syncClient.sendcommand.testGetValue(); + equal(wipeEvents.length, 2); + equal(wipeEvents[0].extra.flow_id, wipeEvents[1].extra.flow_id); + equal(wipeEvents[0].extra.reason, "testing"); + equal(wipeEvents[1].extra.reason, "testing"); + Services.fog.testResetFOG(); + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + + // and when both are specified. + await engine.sendCommand("wipeEngine", ["tabs"], null, { + reason: "testing", + flowID, + }); + await syncClientsEngine(server); + wipeEvents = Glean.syncClient.sendcommand.testGetValue(); + equal(wipeEvents.length, 2); + equal(wipeEvents[0].extra.flow_id, flowID); + equal(wipeEvents[1].extra.flow_id, flowID); + equal(wipeEvents[0].extra.reason, "testing"); + equal(wipeEvents[1].extra.reason, "testing"); + // Wipe remote clients to ensure deduping doesn't prevent us from adding the command. + for (let client of Object.values(engine._store._remoteClients)) { + client.commands = []; + } + } finally { + cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_duplicate_commands_telemetry() { + // Clear events from other test cases. + Services.fog.testResetFOG(); + + let server = await serverForFoo(engine); + try { + await SyncTestingInfrastructure(server); + let collection = server.getCollection("foo", "clients"); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + + _("Create remote client record 1"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + _("Create remote client record 2"); + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "mobile", + commands: [], + version: "48", + protocols: ["1.5"], + }); + + await syncClientsEngine(server); + // Make sure deduping works before syncing + await engine.sendCommand("wipeEngine", ["history"], remoteId); + await engine.sendCommand("wipeEngine", ["history"], remoteId); + equal(Glean.syncClient.sendcommand.testGetValue().length, 1); + await syncClientsEngine(server); + // And after syncing. + await engine.sendCommand("wipeEngine", ["history"], remoteId); + equal(Glean.syncClient.sendcommand.testGetValue().length, 1); + // Ensure we aren't deduping commands to different clients + await engine.sendCommand("wipeEngine", ["history"], remoteId2); + equal(Glean.syncClient.sendcommand.testGetValue().length, 2); + } finally { + cleanup(); + await promiseStopServer(server); + } +}); + +add_task(async function test_other_clients_notified_on_first_sync() { + _( + "Ensure that other clients are notified when we upload our client record for the first time." + ); + + await engine.resetLastSync(); + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + const fxAccounts = engine.fxAccounts; + let calls = 0; + engine.fxAccounts = { + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + }, + notifyDevices() { + calls++; + return Promise.resolve(true); + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + try { + engine.lastRecordUpload = 0; + _("First sync, should notify other clients"); + await syncClientsEngine(server); + equal(calls, 1); + + _("Second sync, should not notify other clients"); + await syncClientsEngine(server); + equal(calls, 1); + } finally { + engine.fxAccounts = fxAccounts; + cleanup(); + await promiseStopServer(server); + } +}); + +add_task( + async function device_disconnected_notification_updates_known_stale_clients() { + const spyUpdate = sinon.spy(engine, "updateKnownStaleClients"); + + Services.obs.notifyObservers( + null, + "fxaccounts:device_disconnected", + JSON.stringify({ isLocalDevice: false }) + ); + ok(spyUpdate.calledOnce, "updateKnownStaleClients should be called"); + spyUpdate.resetHistory(); + + Services.obs.notifyObservers( + null, + "fxaccounts:device_disconnected", + JSON.stringify({ isLocalDevice: true }) + ); + ok(spyUpdate.notCalled, "updateKnownStaleClients should not be called"); + + spyUpdate.restore(); + } +); + +add_task(async function update_known_stale_clients() { + const makeFakeClient = id => ({ id, fxaDeviceId: `fxa-${id}` }); + const clients = [ + makeFakeClient("one"), + makeFakeClient("two"), + makeFakeClient("three"), + ]; + const stubRemoteClients = sinon + .stub(engine._store, "_remoteClients") + .get(() => { + return clients; + }); + const stubFetchFxADevices = sinon + .stub(engine, "_fetchFxADevices") + .callsFake(() => { + engine._knownStaleFxADeviceIds = ["fxa-one", "fxa-two"]; + }); + + engine._knownStaleFxADeviceIds = null; + await engine.updateKnownStaleClients(); + ok(clients[0].stale); + ok(clients[1].stale); + ok(!clients[2].stale); + + stubRemoteClients.restore(); + stubFetchFxADevices.restore(); +}); + +add_task(async function test_create_record_command_limit() { + await engine._store.wipe(); + await generateNewKeys(Service.collectionKeys); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + const fakeLimit = 4 * 1024; + + let maxSizeStub = sinon + .stub(Service, "getMemcacheMaxRecordPayloadSize") + .callsFake(() => fakeLimit); + + let user = server.user("foo"); + let remoteId = Utils.makeGUID(); + + _("Create remote client record"); + user.collection("clients").insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "57", + protocols: ["1.5"], + }); + + try { + _("Initial sync."); + await syncClientsEngine(server); + + _("Send a fairly sane number of commands."); + + for (let i = 0; i < 5; ++i) { + await engine.sendCommand("wipeEngine", [`history: ${i}`], remoteId); + } + + await syncClientsEngine(server); + + _("Make sure they all fit and weren't dropped."); + let parsedServerRecord = user.collection("clients").cleartext(remoteId); + + equal(parsedServerRecord.commands.length, 5); + + await engine.sendCommand("wipeEngine", ["history"], remoteId); + + _("Send a not-sane number of commands."); + // Much higher than the maximum number of commands we could actually fit. + for (let i = 0; i < 500; ++i) { + await engine.sendCommand("wipeEngine", [`tabs: ${i}`], remoteId); + } + + await syncClientsEngine(server); + + _("Ensure we didn't overflow the server limit."); + let wbo = user.collection("clients").wbo(remoteId); + less(wbo.payload.length, fakeLimit); + + _( + "And that the data we uploaded is both sane json and containing some commands." + ); + let remoteCommands = wbo.getCleartext().commands; + greater(remoteCommands.length, 2); + let firstCommand = remoteCommands[0]; + _( + "The first command should still be present, since it had a high priority" + ); + equal(firstCommand.command, "wipeEngine"); + _("And the last command in the list should be the last command we sent."); + let lastCommand = remoteCommands[remoteCommands.length - 1]; + equal(lastCommand.command, "wipeEngine"); + deepEqual(lastCommand.args, ["tabs: 499"]); + } finally { + maxSizeStub.restore(); + await cleanup(); + try { + let collection = server.getCollection("foo", "clients"); + collection.remove(remoteId); + } finally { + await promiseStopServer(server); + } + } +}); diff --git a/src/zen/tests/mochitests/sync/test_clients_escape.js b/src/zen/tests/mochitests/sync/test_clients_escape.js new file mode 100644 index 000000000..62ffcf1ff --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_clients_escape.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function test_clients_escape() { + _("Set up test fixtures."); + + await configureIdentity(); + let keyBundle = Service.identity.syncKeyBundle; + + let engine = Service.clientsEngine; + + try { + _("Test that serializing client records results in uploadable ascii"); + engine.localID = "ascii"; + engine.localName = "wéävê"; + + _("Make sure we have the expected record"); + let record = await engine._createRecord("ascii"); + Assert.equal(record.id, "ascii"); + Assert.equal(record.name, "wéävê"); + + _("Encrypting record..."); + await record.encrypt(keyBundle); + _("Encrypted."); + + let serialized = JSON.stringify(record); + let checkCount = 0; + _("Checking for all ASCII:", serialized); + for (let ch of serialized) { + let code = ch.charCodeAt(0); + _("Checking asciiness of '", ch, "'=", code); + Assert.less(code, 128); + checkCount++; + } + + _("Processed", checkCount, "characters out of", serialized.length); + Assert.equal(checkCount, serialized.length); + + _("Making sure the record still looks like it did before"); + await record.decrypt(keyBundle); + Assert.equal(record.id, "ascii"); + Assert.equal(record.name, "wéävê"); + + _("Sanity check that creating the record also gives the same"); + record = await engine._createRecord("ascii"); + Assert.equal(record.id, "ascii"); + Assert.equal(record.name, "wéävê"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); diff --git a/src/zen/tests/mochitests/sync/test_collection_getBatched.js b/src/zen/tests/mochitests/sync/test_collection_getBatched.js new file mode 100644 index 000000000..f5425abe9 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_collection_getBatched.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Collection, WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function recordRange(lim, offset, total) { + let res = []; + for (let i = offset; i < Math.min(lim + offset, total); ++i) { + res.push({ id: String(i), payload: "test:" + i }); + } + return res; +} + +function get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + throwAfter = Infinity, + interruptedAfter = Infinity, +}) { + let coll = new Collection("http://example.com/test/", WBORecord, Service); + coll.full = true; + let requests = []; + let responses = []; + coll.get = async function () { + let limit = +this.limit; + let offset = 0; + if (this.offset) { + equal(this.offset.slice(0, 6), "foobar"); + offset = +this.offset.slice(6); + } + requests.push({ + limit, + offset, + spec: this.spec, + headers: Object.assign({}, this.headers), + }); + if (--throwAfter === 0) { + throw new Error("Some Network Error"); + } + let body = recordRange(limit, offset, totalRecords); + let response = { + obj: body, + success: true, + status: 200, + headers: {}, + }; + if (--interruptedAfter === 0) { + response.success = false; + response.status = 412; + response.body = ""; + } else if (offset + limit < totalRecords) { + // Ensure we're treating this as an opaque string, since the docs say + // it might not be numeric. + response.headers["x-weave-next-offset"] = "foobar" + (offset + batchSize); + } + response.headers["x-last-modified"] = lastModified; + responses.push(response); + return response; + }; + return { responses, requests, coll }; +} + +add_task(async function test_success() { + const totalRecords = 11; + const batchSize = 2; + const lastModified = "111111"; + let { responses, requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + }); + let { response, records } = await coll.getBatched(batchSize); + + equal(requests.length, Math.ceil(totalRecords / batchSize)); + + equal(records.length, totalRecords); + checkRecordsOrder(records); + + // ensure we're returning the last response + equal(responses[responses.length - 1], response); + + // check first separately since its a bit of a special case + ok(!requests[0].headers["x-if-unmodified-since"]); + ok(!requests[0].offset); + equal(requests[0].limit, batchSize); + let expectedOffset = 2; + for (let i = 1; i < requests.length; ++i) { + let req = requests[i]; + equal(req.headers["x-if-unmodified-since"], lastModified); + equal(req.limit, batchSize); + if (i !== requests.length - 1) { + equal(req.offset, expectedOffset); + } + + expectedOffset += batchSize; + } + + // ensure we cleaned up anything that would break further + // use of this collection. + ok(!coll._headers["x-if-unmodified-since"]); + ok(!coll.offset); + ok(!coll.limit || coll.limit == Infinity); +}); + +add_task(async function test_total_limit() { + _("getBatched respects the (initial) value of the limit property"); + const totalRecords = 100; + const recordLimit = 11; + const batchSize = 2; + const lastModified = "111111"; + let { requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + }); + coll.limit = recordLimit; + let { records } = await coll.getBatched(batchSize); + checkRecordsOrder(records); + + equal(requests.length, Math.ceil(recordLimit / batchSize)); + equal(records.length, recordLimit); + + for (let i = 0; i < requests.length; ++i) { + let req = requests[i]; + if (i !== requests.length - 1) { + equal(req.limit, batchSize); + } else { + equal(req.limit, recordLimit % batchSize); + } + } + + equal(coll._limit, recordLimit); +}); + +add_task(async function test_412() { + _("We shouldn't record records if we get a 412 in the middle of a batch"); + const totalRecords = 11; + const batchSize = 2; + const lastModified = "111111"; + let { responses, requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + interruptedAfter: 3, + }); + let { response, records } = await coll.getBatched(batchSize); + + equal(requests.length, 3); + equal(records.length, 0); // we should not get any records + + // ensure we're returning the last response + equal(responses[responses.length - 1], response); + + ok(!response.success); + equal(response.status, 412); +}); + +add_task(async function test_get_throws() { + _("getBatched() should throw if a get() throws"); + const totalRecords = 11; + const batchSize = 2; + const lastModified = "111111"; + let { requests, coll } = get_test_collection_info({ + totalRecords, + batchSize, + lastModified, + throwAfter: 3, + }); + + await Assert.rejects(coll.getBatched(batchSize), /Some Network Error/); + + equal(requests.length, 3); +}); + +function checkRecordsOrder(records) { + ok(!!records.length); + for (let i = 0; i < records.length; i++) { + equal(records[i].id, String(i)); + equal(records[i].payload, "test:" + i); + } +} diff --git a/src/zen/tests/mochitests/sync/test_collections_recovery.js b/src/zen/tests/mochitests/sync/test_collections_recovery.js new file mode 100644 index 000000000..fd923bc27 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_collections_recovery.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verify that we wipe the server if we have to regenerate keys. +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function test_missing_crypto_collection() { + enableValidationPrefs(); + + let johnHelper = track_collections_helper(); + let johnU = johnHelper.with_updated_collection; + let johnColls = johnHelper.collections; + + let empty = false; + function maybe_empty(handler) { + return function (request, response) { + if (empty) { + let body = "{}"; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + } else { + handler(request, response); + } + }; + } + + let handlers = { + "/1.1/johndoe/info/collections": maybe_empty(johnHelper.handler), + "/1.1/johndoe/storage/crypto/keys": johnU( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": johnU( + "meta", + new ServerWBO("global").handler() + ), + }; + let collections = [ + "clients", + "bookmarks", + "forms", + "history", + "passwords", + "prefs", + "tabs", + ]; + // Disable addon sync because AddonManager won't be initialized here. + await Service.engineManager.unregister("addons"); + await Service.engineManager.unregister("extension-storage"); + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = johnU( + coll, + new ServerCollection({}, true).handler() + ); + } + let server = httpd_setup(handlers); + await configureIdentity({ username: "johndoe" }, server); + + try { + let fresh = 0; + let orig = Service._freshStart; + Service._freshStart = async function () { + _("Called _freshStart."); + await orig.call(Service); + fresh++; + }; + + _("Startup, no meta/global: freshStart called once."); + await sync_and_validate_telem(); + Assert.equal(fresh, 1); + fresh = 0; + + _("Regular sync: no need to freshStart."); + await Service.sync(); + Assert.equal(fresh, 0); + + _("Simulate a bad info/collections."); + delete johnColls.crypto; + await sync_and_validate_telem(); + Assert.equal(fresh, 1); + fresh = 0; + + _("Regular sync: no need to freshStart."); + await sync_and_validate_telem(); + Assert.equal(fresh, 0); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_corrupt_keys.js b/src/zen/tests/mochitests/sync/test_corrupt_keys.js new file mode 100644 index 000000000..7caea85d4 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_corrupt_keys.js @@ -0,0 +1,248 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); +const { HistoryEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/history.sys.mjs" +); +const { CryptoWrapper, WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function test_locally_changed_keys() { + enableValidationPrefs(); + + let hmacErrorCount = 0; + function counting(f) { + return async function () { + hmacErrorCount++; + return f.call(this); + }; + } + + Service.handleHMACEvent = counting(Service.handleHMACEvent); + + let server = new SyncServer(); + let johndoe = server.registerUser("johndoe", "password"); + johndoe.createContents({ + meta: {}, + crypto: {}, + clients: {}, + }); + server.start(); + + try { + Svc.PrefBranch.setStringPref("registerEngines", "Tab"); + + await configureIdentity({ username: "johndoe" }, server); + // We aren't doing a .login yet, so fudge the cluster URL. + Service.clusterURL = Service.identity._token.endpoint; + + await Service.engineManager.register(HistoryEngine); + // Disable addon sync because AddonManager won't be initialized here. + await Service.engineManager.unregister("addons"); + await Service.engineManager.unregister("extension-storage"); + + async function corrupt_local_keys() { + Service.collectionKeys._default.keyPair = [ + await Weave.Crypto.generateRandomKey(), + await Weave.Crypto.generateRandomKey(), + ]; + } + + _("Setting meta."); + + // Bump version on the server. + let m = new WBORecord("meta", "global"); + m.payload = { + syncID: "foooooooooooooooooooooooooo", + storageVersion: STORAGE_VERSION, + }; + await m.upload(Service.resource(Service.metaURL)); + + _( + "New meta/global: " + + JSON.stringify(johndoe.collection("meta").wbo("global")) + ); + + // Upload keys. + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + Assert.ok( + (await serverKeys.upload(Service.resource(Service.cryptoKeysURL))).success + ); + + // Check that login works. + Assert.ok(await Service.login()); + Assert.ok(Service.isLoggedIn); + + // Sync should upload records. + await sync_and_validate_telem(); + + // Tabs exist. + _("Tabs modified: " + johndoe.modified("tabs")); + Assert.greater(johndoe.modified("tabs"), 0); + + // Let's create some server side history records. + let liveKeys = Service.collectionKeys.keyForCollection("history"); + _("Keys now: " + liveKeys.keyPair); + let visitType = Ci.nsINavHistoryService.TRANSITION_LINK; + let history = johndoe.createCollection("history"); + for (let i = 0; i < 5; i++) { + let id = "record-no--" + i; + let modified = Date.now() / 1000 - 60 * (i + 10); + + let w = new CryptoWrapper("history", "id"); + w.cleartext = { + id, + histUri: "http://foo/bar?" + id, + title: id, + sortindex: i, + visits: [{ date: (modified - 5) * 1000000, type: visitType }], + deleted: false, + }; + await w.encrypt(liveKeys); + + let payload = { ciphertext: w.ciphertext, IV: w.IV, hmac: w.hmac }; + history.insert(id, payload, modified); + } + + history.timestamp = Date.now() / 1000; + let old_key_time = johndoe.modified("crypto"); + _("Old key time: " + old_key_time); + + // Check that we can decrypt one. + let rec = new CryptoWrapper("history", "record-no--0"); + await rec.fetch( + Service.resource(Service.storageURL + "history/record-no--0") + ); + _(JSON.stringify(rec)); + Assert.ok(!!(await rec.decrypt(liveKeys))); + + Assert.equal(hmacErrorCount, 0); + + // Fill local key cache with bad data. + await corrupt_local_keys(); + _( + "Keys now: " + Service.collectionKeys.keyForCollection("history").keyPair + ); + + Assert.equal(hmacErrorCount, 0); + + _("HMAC error count: " + hmacErrorCount); + // Now syncing should succeed, after one HMAC error. + await sync_and_validate_telem(ping => { + Assert.equal( + ping.engines.find(e => e.name == "history").incoming.applied, + 5 + ); + }); + + Assert.equal(hmacErrorCount, 1); + _( + "Keys now: " + Service.collectionKeys.keyForCollection("history").keyPair + ); + + // And look! We downloaded history! + Assert.ok( + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--0") + ); + Assert.ok( + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--1") + ); + Assert.ok( + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--2") + ); + Assert.ok( + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--3") + ); + Assert.ok( + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--4") + ); + Assert.equal(hmacErrorCount, 1); + + _("Busting some new server values."); + // Now what happens if we corrupt the HMAC on the server? + for (let i = 5; i < 10; i++) { + let id = "record-no--" + i; + let modified = 1 + Date.now() / 1000; + + let w = new CryptoWrapper("history", "id"); + w.cleartext = { + id, + histUri: "http://foo/bar?" + id, + title: id, + sortindex: i, + visits: [{ date: (modified - 5) * 1000000, type: visitType }], + deleted: false, + }; + await w.encrypt(Service.collectionKeys.keyForCollection("history")); + w.hmac = w.hmac.toUpperCase(); + + let payload = { ciphertext: w.ciphertext, IV: w.IV, hmac: w.hmac }; + history.insert(id, payload, modified); + } + history.timestamp = Date.now() / 1000; + + _("Server key time hasn't changed."); + Assert.equal(johndoe.modified("crypto"), old_key_time); + + _("Resetting HMAC error timer."); + Service.lastHMACEvent = 0; + + _("Syncing..."); + await sync_and_validate_telem(ping => { + Assert.equal( + ping.engines.find(e => e.name == "history").incoming.failed, + 5 + ); + }); + + _( + "Keys now: " + Service.collectionKeys.keyForCollection("history").keyPair + ); + _( + "Server keys have been updated, and we skipped over 5 more HMAC errors without adjusting history." + ); + Assert.greater(johndoe.modified("crypto"), old_key_time); + Assert.equal(hmacErrorCount, 6); + Assert.equal( + false, + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--5") + ); + Assert.equal( + false, + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--6") + ); + Assert.equal( + false, + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--7") + ); + Assert.equal( + false, + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--8") + ); + Assert.equal( + false, + await PlacesUtils.history.hasVisits("http://foo/bar?record-no--9") + ); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); + +function run_test() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + validate_all_future_pings(); + + run_next_test(); +} diff --git a/src/zen/tests/mochitests/sync/test_declined.js b/src/zen/tests/mochitests/sync/test_declined.js new file mode 100644 index 000000000..ccf663d0e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_declined.js @@ -0,0 +1,198 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { DeclinedEngines } = ChromeUtils.importESModule( + "resource://services-sync/stages/declined.sys.mjs" +); +const { EngineSynchronizer } = ChromeUtils.importESModule( + "resource://services-sync/stages/enginesync.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Observers } = ChromeUtils.importESModule( + "resource://services-common/observers.sys.mjs" +); + +function PetrolEngine() {} +PetrolEngine.prototype.name = "petrol"; + +function DieselEngine() {} +DieselEngine.prototype.name = "diesel"; + +function DummyEngine() {} +DummyEngine.prototype.name = "dummy"; + +function ActualEngine() {} +ActualEngine.prototype.name = "actual"; +Object.setPrototypeOf(ActualEngine.prototype, SyncEngine.prototype); + +function getEngineManager() { + let manager = new EngineManager(Service); + Service.engineManager = manager; + manager._engines = { + petrol: new PetrolEngine(), + diesel: new DieselEngine(), + dummy: new DummyEngine(), + actual: new ActualEngine(), + }; + return manager; +} + +/** + * 'Fetch' a meta/global record that doesn't mention declined. + * + * Push it into the EngineSynchronizer to set enabled; verify that those are + * correct. + * + * Then push it into DeclinedEngines to set declined; verify that none are + * declined, and a notification is sent for our locally disabled-but-not- + * declined engines. + */ +add_task(async function testOldMeta() { + let meta = { + payload: { + engines: { + petrol: 1, + diesel: 2, + nonlocal: 3, // Enabled but not supported. + }, + }, + }; + + _("Record: " + JSON.stringify(meta)); + + let manager = getEngineManager(); + + // Update enabled from meta/global. + let engineSync = new EngineSynchronizer(Service); + await engineSync._updateEnabledFromMeta(meta, 3, manager); + + Assert.ok(manager._engines.petrol.enabled, "'petrol' locally enabled."); + Assert.ok(manager._engines.diesel.enabled, "'diesel' locally enabled."); + Assert.ok( + !("nonlocal" in manager._engines), + "We don't know anything about the 'nonlocal' engine." + ); + Assert.ok(!manager._engines.actual.enabled, "'actual' not locally enabled."); + Assert.ok(!manager.isDeclined("actual"), "'actual' not declined, though."); + + let declinedEngines = new DeclinedEngines(Service); + + function onNotDeclined(subject) { + Observers.remove("weave:engines:notdeclined", onNotDeclined); + Assert.ok( + subject.undecided.has("actual"), + "EngineManager observed that 'actual' was undecided." + ); + + let declined = manager.getDeclined(); + _("Declined: " + JSON.stringify(declined)); + + Assert.ok(!meta.changed, "No need to upload a new meta/global."); + run_next_test(); + } + + Observers.add("weave:engines:notdeclined", onNotDeclined); + + declinedEngines.updateDeclined(meta, manager); +}); + +/** + * 'Fetch' a meta/global that declines an engine we don't + * recognize. Ensure that we track that declined engine along + * with any we locally declined, and that the meta/global + * record is marked as changed and includes all declined + * engines. + */ +add_task(async function testDeclinedMeta() { + let meta = { + payload: { + engines: { + petrol: 1, + diesel: 2, + nonlocal: 3, // Enabled but not supported. + }, + declined: ["nonexistent"], // Declined and not supported. + }, + }; + + _("Record: " + JSON.stringify(meta)); + + let manager = getEngineManager(); + manager._engines.petrol.enabled = true; + manager._engines.diesel.enabled = true; + manager._engines.dummy.enabled = true; + manager._engines.actual.enabled = false; // Disabled but not declined. + + manager.decline(["localdecline"]); // Declined and not supported. + + let declinedEngines = new DeclinedEngines(Service); + + function onNotDeclined(subject) { + Observers.remove("weave:engines:notdeclined", onNotDeclined); + Assert.ok( + subject.undecided.has("actual"), + "EngineManager observed that 'actual' was undecided." + ); + + let declined = manager.getDeclined(); + _("Declined: " + JSON.stringify(declined)); + + Assert.equal( + declined.indexOf("actual"), + -1, + "'actual' is locally disabled, but not marked as declined." + ); + + Assert.equal( + declined.indexOf("clients"), + -1, + "'clients' is enabled and not remotely declined." + ); + Assert.equal( + declined.indexOf("petrol"), + -1, + "'petrol' is enabled and not remotely declined." + ); + Assert.equal( + declined.indexOf("diesel"), + -1, + "'diesel' is enabled and not remotely declined." + ); + Assert.equal( + declined.indexOf("dummy"), + -1, + "'dummy' is enabled and not remotely declined." + ); + + Assert.lessOrEqual( + 0, + declined.indexOf("nonexistent"), + "'nonexistent' was declined on the server." + ); + + Assert.lessOrEqual( + 0, + declined.indexOf("localdecline"), + "'localdecline' was declined locally." + ); + + // The meta/global is modified, too. + Assert.lessOrEqual( + 0, + meta.payload.declined.indexOf("nonexistent"), + "meta/global's declined contains 'nonexistent'." + ); + Assert.lessOrEqual( + 0, + meta.payload.declined.indexOf("localdecline"), + "meta/global's declined contains 'localdecline'." + ); + Assert.strictEqual(true, meta.changed, "meta/global was changed."); + } + + Observers.add("weave:engines:notdeclined", onNotDeclined); + + declinedEngines.updateDeclined(meta, manager); +}); diff --git a/src/zen/tests/mochitests/sync/test_disconnect_shutdown.js b/src/zen/tests/mochitests/sync/test_disconnect_shutdown.js new file mode 100644 index 000000000..1c0023824 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_disconnect_shutdown.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SyncDisconnect, SyncDisconnectInternal } = ChromeUtils.importESModule( + "resource://services-sync/SyncDisconnect.sys.mjs" +); +const { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); +const { PREF_LAST_FXA_USER_UID } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); + +add_task(async function test_shutdown_blocker() { + let spySignout = sinon.stub( + SyncDisconnectInternal, + "doSyncAndAccountDisconnect" + ); + + // We don't need to check for the lock regularly as we end up aborting the wait. + SyncDisconnectInternal.lockRetryInterval = 1000; + // Force the retry count to a very large value - this test should never + // abort due to the retry count and we want the test to fail (aka timeout) + // should our abort code not work. + SyncDisconnectInternal.lockRetryCount = 10000; + // mock the "browser" sanitize function - it should not be called by + // this test. + let spyBrowser = sinon.stub(SyncDisconnectInternal, "doSanitizeBrowserData"); + // mock Sync + let mockEngine1 = { + enabled: true, + name: "Test Engine 1", + wipeClient: sinon.spy(), + }; + let mockEngine2 = { + enabled: false, + name: "Test Engine 2", + wipeClient: sinon.spy(), + }; + + // This weave mock never gives up the lock. + let Weave = { + Service: { + enabled: true, + lock: () => false, // so we never get the lock. + unlock: sinon.spy(), + + engineManager: { + getAll: sinon.stub().returns([mockEngine1, mockEngine2]), + }, + errorHandler: { + resetFileLog: sinon.spy(), + }, + }, + }; + let weaveStub = sinon.stub(SyncDisconnectInternal, "getWeave"); + weaveStub.returns(Weave); + + Services.prefs.setStringPref( + PREF_LAST_FXA_USER_UID, + "dGVzdEBleGFtcGxlLmNvbQ==" + ); + + let promiseDisconnected = SyncDisconnect.disconnect(true); + + // Pretend we hit the shutdown blocker. + info("simulating appShutdownConfirmed"); + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.appShutdownConfirmed._trigger(); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + + info("waiting for disconnect to complete"); + await promiseDisconnected; + + Assert.ok( + !Services.prefs.prefHasUserValue(PREF_LAST_FXA_USER_UID), + "Should have reset different user warning pref" + ); + Assert.equal( + Weave.Service.unlock.callCount, + 0, + "should not have unlocked at the end" + ); + Assert.ok(!Weave.Service.enabled, "Weave should be and remain disabled"); + Assert.equal( + Weave.Service.errorHandler.resetFileLog.callCount, + 1, + "should have reset the log" + ); + Assert.equal( + mockEngine1.wipeClient.callCount, + 1, + "enabled engine should have been wiped" + ); + Assert.equal( + mockEngine2.wipeClient.callCount, + 0, + "disabled engine should not have been wiped" + ); + Assert.equal(spyBrowser.callCount, 1, "should not sanitize the browser"); + Assert.equal(spySignout.callCount, 1, "should have signed out of FxA"); +}); diff --git a/src/zen/tests/mochitests/sync/test_engine.js b/src/zen/tests/mochitests/sync/test_engine.js new file mode 100644 index 000000000..dc38c3626 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_engine.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Observers } = ChromeUtils.importESModule( + "resource://services-common/observers.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function SteamStore(engine) { + Store.call(this, "Steam", engine); + this.wasWiped = false; +} +SteamStore.prototype = { + async wipe() { + this.wasWiped = true; + }, +}; +Object.setPrototypeOf(SteamStore.prototype, Store.prototype); + +function SteamTracker(name, engine) { + LegacyTracker.call(this, name || "Steam", engine); +} +Object.setPrototypeOf(SteamTracker.prototype, LegacyTracker.prototype); + +function SteamEngine(name, service) { + SyncEngine.call(this, name, service); + this.wasReset = false; + this.wasSynced = false; +} +SteamEngine.prototype = { + _storeObj: SteamStore, + _trackerObj: SteamTracker, + + async _resetClient() { + this.wasReset = true; + }, + + async _sync() { + this.wasSynced = true; + }, +}; +Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype); + +var engineObserver = { + topics: [], + + observe(subject, topic, data) { + Assert.equal(data, "steam"); + this.topics.push(topic); + }, + + reset() { + this.topics = []; + }, +}; +Observers.add("weave:engine:reset-client:start", engineObserver); +Observers.add("weave:engine:reset-client:finish", engineObserver); +Observers.add("weave:engine:wipe-client:start", engineObserver); +Observers.add("weave:engine:wipe-client:finish", engineObserver); +Observers.add("weave:engine:sync:start", engineObserver); +Observers.add("weave:engine:sync:finish", engineObserver); + +async function cleanup(engine) { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + engine.wasReset = false; + engine.wasSynced = false; + engineObserver.reset(); + await engine._tracker.clearChangedIDs(); + await engine.finalize(); +} + +add_task(async function test_members() { + _("Engine object members"); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + Assert.equal(engine.Name, "Steam"); + Assert.equal(engine.prefName, "steam"); + Assert.ok(engine._store instanceof SteamStore); + Assert.ok(engine._tracker instanceof SteamTracker); +}); + +add_task(async function test_score() { + _("Engine.score corresponds to tracker.score and is readonly"); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + Assert.equal(engine.score, 0); + engine._tracker.score += 5; + Assert.equal(engine.score, 5); + + try { + engine.score = 10; + } catch (ex) { + // Setting an attribute that has a getter produces an error in + // Firefox <= 3.6 and is ignored in later versions. Either way, + // the attribute's value won't change. + } + Assert.equal(engine.score, 5); +}); + +add_task(async function test_resetClient() { + _("Engine.resetClient calls _resetClient"); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + Assert.ok(!engine.wasReset); + + await engine.resetClient(); + Assert.ok(engine.wasReset); + Assert.equal(engineObserver.topics[0], "weave:engine:reset-client:start"); + Assert.equal(engineObserver.topics[1], "weave:engine:reset-client:finish"); + + await cleanup(engine); +}); + +add_task(async function test_invalidChangedIDs() { + _("Test that invalid changed IDs on disk don't end up live."); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + let tracker = engine._tracker; + + await tracker._beforeSave(); + await IOUtils.writeUTF8(tracker._storage.path, "5", { + tmpPath: tracker._storage.path + ".tmp", + }); + + ok(!tracker._storage.dataReady); + const changes = await tracker.getChangedIDs(); + changes.placeholder = true; + deepEqual( + changes, + { placeholder: true }, + "Accessing changed IDs should load changes from disk as a side effect" + ); + ok(tracker._storage.dataReady); + + Assert.ok(changes.placeholder); + await cleanup(engine); +}); + +add_task(async function test_wipeClient() { + _("Engine.wipeClient calls resetClient, wipes store, clears changed IDs"); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + Assert.ok(!engine.wasReset); + Assert.ok(!engine._store.wasWiped); + Assert.ok(await engine._tracker.addChangedID("a-changed-id")); + let changes = await engine._tracker.getChangedIDs(); + Assert.ok("a-changed-id" in changes); + + await engine.wipeClient(); + Assert.ok(engine.wasReset); + Assert.ok(engine._store.wasWiped); + changes = await engine._tracker.getChangedIDs(); + Assert.equal(JSON.stringify(changes), "{}"); + Assert.equal(engineObserver.topics[0], "weave:engine:wipe-client:start"); + Assert.equal(engineObserver.topics[1], "weave:engine:reset-client:start"); + Assert.equal(engineObserver.topics[2], "weave:engine:reset-client:finish"); + Assert.equal(engineObserver.topics[3], "weave:engine:wipe-client:finish"); + + await cleanup(engine); +}); + +add_task(async function test_enabled() { + _("Engine.enabled corresponds to preference"); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + try { + Assert.ok(!engine.enabled); + Svc.PrefBranch.setBoolPref("engine.steam", true); + Assert.ok(engine.enabled); + + engine.enabled = false; + Assert.ok(!Svc.PrefBranch.getBoolPref("engine.steam")); + } finally { + await cleanup(engine); + } +}); + +add_task(async function test_sync() { + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + try { + _("Engine.sync doesn't call _sync if it's not enabled"); + Assert.ok(!engine.enabled); + Assert.ok(!engine.wasSynced); + await engine.sync(); + + Assert.ok(!engine.wasSynced); + + _("Engine.sync calls _sync if it's enabled"); + engine.enabled = true; + + await engine.sync(); + Assert.ok(engine.wasSynced); + Assert.equal(engineObserver.topics[0], "weave:engine:sync:start"); + Assert.equal(engineObserver.topics[1], "weave:engine:sync:finish"); + } finally { + await cleanup(engine); + } +}); + +add_task(async function test_disabled_no_track() { + _("When an engine is disabled, its tracker is not tracking."); + let engine = new SteamEngine("Steam", Service); + await engine.initialize(); + let tracker = engine._tracker; + Assert.equal(engine, tracker.engine); + + Assert.ok(!engine.enabled); + Assert.ok(!tracker._isTracking); + let changes = await tracker.getChangedIDs(); + do_check_empty(changes); + + Assert.ok(!tracker.engineIsEnabled()); + Assert.ok(!tracker._isTracking); + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + + let promisePrefChangeHandled = Promise.withResolvers(); + const origMethod = tracker.onEngineEnabledChanged; + tracker.onEngineEnabledChanged = async (...args) => { + await origMethod.apply(tracker, args); + promisePrefChangeHandled.resolve(); + }; + + engine.enabled = true; // Also enables the tracker automatically. + await promisePrefChangeHandled.promise; + Assert.ok(tracker._isTracking); + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + + await tracker.addChangedID("abcdefghijkl"); + changes = await tracker.getChangedIDs(); + Assert.less(0, changes.abcdefghijkl); + promisePrefChangeHandled = Promise.withResolvers(); + Svc.PrefBranch.setBoolPref("engine." + engine.prefName, false); + await promisePrefChangeHandled.promise; + Assert.ok(!tracker._isTracking); + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + + await cleanup(engine); +}); diff --git a/src/zen/tests/mochitests/sync/test_engine_abort.js b/src/zen/tests/mochitests/sync/test_engine_abort.js new file mode 100644 index 000000000..a7c62afb4 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_engine_abort.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { RotaryEngine } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/rotaryengine.sys.mjs" +); + +add_task(async function test_processIncoming_abort() { + _( + "An abort exception, raised in applyIncoming, will abort _processIncoming." + ); + let engine = new RotaryEngine(Service); + + let collection = new ServerCollection(); + let id = Utils.makeGUID(); + let payload = encryptPayload({ id, denomination: "Record No. " + id }); + collection.insert(id, payload); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + _("Create some server data."); + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + _("Fake applyIncoming to abort."); + engine._store.applyIncoming = async function () { + let ex = { + code: SyncEngine.prototype.eEngineAbortApplyIncoming, + cause: "Nooo", + }; + _("Throwing: " + JSON.stringify(ex)); + throw ex; + }; + + _("Trying _processIncoming. It will throw after aborting."); + let err; + try { + await engine._syncStartup(); + await engine._processIncoming(); + } catch (ex) { + err = ex; + } + + Assert.equal(err, "Nooo"); + err = undefined; + + _("Trying engine.sync(). It will abort without error."); + try { + // This will quietly fail. + await engine.sync(); + } catch (ex) { + err = ex; + } + + Assert.equal(err, undefined); + + await promiseStopServer(server); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + + await engine._tracker.clearChangedIDs(); + await engine.finalize(); +}); diff --git a/src/zen/tests/mochitests/sync/test_engine_changes_during_sync.js b/src/zen/tests/mochitests/sync/test_engine_changes_during_sync.js new file mode 100644 index 000000000..891bea41e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_engine_changes_during_sync.js @@ -0,0 +1,611 @@ +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Bookmark, BookmarkFolder, BookmarkQuery } = ChromeUtils.importESModule( + "resource://services-sync/engines/bookmarks.sys.mjs" +); +const { HistoryRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/history.sys.mjs" +); +const { FormRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/forms.sys.mjs" +); +const { LoginRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { PrefRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/prefs.sys.mjs" +); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +/** + * We don't test the clients or tabs engines because neither has conflict + * resolution logic. The clients engine syncs twice per global sync, and + * custom conflict resolution logic for commands that doesn't use + * timestamps. Tabs doesn't have conflict resolution at all, since it's + * read-only. + */ + +async function assertChildGuids(folderGuid, expectedChildGuids, message) { + let tree = await PlacesUtils.promiseBookmarksTree(folderGuid); + let childGuids = tree.children.map(child => child.guid); + deepEqual(childGuids, expectedChildGuids, message); +} + +async function cleanup(engine, server) { + await engine._tracker.stop(); + await engine._store.wipe(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + await promiseStopServer(server); +} + +add_task(async function test_history_change_during_sync() { + _("Ensure that we don't bump the score when applying history records."); + + enableValidationPrefs(); + + let engine = Service.engineManager.get("history"); + let server = await serverForEnginesWithKeys({ foo: "password" }, [engine]); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("history"); + + // Override `uploadOutgoing` to insert a record while we're applying + // changes. The tracker should ignore this change. + let uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async function () { + engine._uploadOutgoing = uploadOutgoing; + try { + await uploadOutgoing.call(this); + } finally { + _("Inserting local history visit"); + await addVisit("during_sync"); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + }; + + engine._tracker.start(); + + try { + let remoteRec = new HistoryRec("history", "UrOOuzE5QM-e"); + remoteRec.histUri = "http://getfirefox.com/"; + remoteRec.title = "Get Firefox!"; + remoteRec.visits = [ + { + date: PlacesUtils.toPRTime(Date.now()), + type: PlacesUtils.history.TRANSITION_TYPED, + }, + ]; + collection.insert(remoteRec.id, encryptPayload(remoteRec.cleartext)); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score for visits added during sync" + ); + + equal( + collection.count(), + 1, + "New local visit should not exist on server after first sync" + ); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score during second history sync" + ); + + equal( + collection.count(), + 2, + "New local visit should exist on server after second sync" + ); + } finally { + engine._uploadOutgoing = uploadOutgoing; + await cleanup(engine, server); + } +}); + +add_task(async function test_passwords_change_during_sync() { + _("Ensure that we don't bump the score when applying passwords."); + + enableValidationPrefs(); + + let engine = Service.engineManager.get("passwords"); + let server = await serverForEnginesWithKeys({ foo: "password" }, [engine]); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("passwords"); + + let uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async function () { + engine._uploadOutgoing = uploadOutgoing; + try { + await uploadOutgoing.call(this); + } finally { + _("Inserting local password"); + let login = new LoginInfo( + "https://example.com", + "", + null, + "username", + "password", + "", + "" + ); + await Services.logins.addLoginAsync(login); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + }; + + engine._tracker.start(); + + try { + let remoteRec = new LoginRec( + "passwords", + "{765e3d6e-071d-d640-a83d-81a7eb62d3ed}" + ); + remoteRec.formSubmitURL = ""; + remoteRec.httpRealm = ""; + remoteRec.hostname = "https://mozilla.org"; + remoteRec.username = "username"; + remoteRec.password = "sekrit"; + remoteRec.timeCreated = Date.now(); + remoteRec.timePasswordChanged = Date.now(); + collection.insert(remoteRec.id, encryptPayload(remoteRec.cleartext)); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score for passwords added during first sync" + ); + + equal( + collection.count(), + 1, + "New local password should not exist on server after first sync" + ); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score during second passwords sync" + ); + + equal( + collection.count(), + 2, + "New local password should exist on server after second sync" + ); + } finally { + engine._uploadOutgoing = uploadOutgoing; + await cleanup(engine, server); + } +}); + +add_task(async function test_prefs_change_during_sync() { + _("Ensure that we don't bump the score when applying prefs."); + + const TEST_PREF = "test.duringSync"; + // create a "control pref" for the pref we sync. + Services.prefs.setBoolPref("services.sync.prefs.sync.test.duringSync", true); + + enableValidationPrefs(); + + let engine = Service.engineManager.get("prefs"); + let server = await serverForEnginesWithKeys({ foo: "password" }, [engine]); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("prefs"); + + let uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async function () { + engine._uploadOutgoing = uploadOutgoing; + try { + await uploadOutgoing.call(this); + } finally { + _("Updating local pref value"); + // Change the value of a synced pref. + Services.prefs.setStringPref(TEST_PREF, "hello"); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + }; + + engine._tracker.start(); + + try { + // All synced prefs are stored in a single record, so we'll only ever + // have one record on the server. This test just checks that we don't + // track or upload prefs changed during the sync. + let guid = CommonUtils.encodeBase64URL(Services.appinfo.ID); + let remoteRec = new PrefRec("prefs", guid); + remoteRec.value = { + [TEST_PREF]: "world", + }; + collection.insert(remoteRec.id, encryptPayload(remoteRec.cleartext)); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score for prefs added during first sync" + ); + let payloads = collection.payloads(); + equal( + payloads.length, + 1, + "Should not upload multiple prefs records after first sync" + ); + equal( + payloads[0].value[TEST_PREF], + "world", + "Should not upload pref value changed during first sync" + ); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score during second prefs sync" + ); + payloads = collection.payloads(); + equal( + payloads.length, + 1, + "Should not upload multiple prefs records after second sync" + ); + equal( + payloads[0].value[TEST_PREF], + "hello", + "Should upload changed pref value during second sync" + ); + } finally { + engine._uploadOutgoing = uploadOutgoing; + await cleanup(engine, server); + Services.prefs.clearUserPref(TEST_PREF); + } +}); + +add_task(async function test_forms_change_during_sync() { + _("Ensure that we don't bump the score when applying form records."); + + enableValidationPrefs(); + + let engine = Service.engineManager.get("forms"); + let server = await serverForEnginesWithKeys({ foo: "password" }, [engine]); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("forms"); + + let uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async function () { + engine._uploadOutgoing = uploadOutgoing; + try { + await uploadOutgoing.call(this); + } finally { + _("Inserting local form history entry"); + await FormHistory.update([ + { + op: "add", + fieldname: "favoriteDrink", + value: "cocoa", + }, + ]); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + }; + + engine._tracker.start(); + + try { + // Add an existing remote form history entry. We shouldn't bump the score when + // we apply this record. + let remoteRec = new FormRec("forms", "Tl9dHgmJSR6FkyxS"); + remoteRec.name = "name"; + remoteRec.value = "alice"; + collection.insert(remoteRec.id, encryptPayload(remoteRec.cleartext)); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score for forms added during first sync" + ); + + equal( + collection.count(), + 1, + "New local form should not exist on server after first sync" + ); + + await sync_engine_and_validate_telem(engine, true); + strictEqual( + Service.scheduler.globalScore, + 0, + "Should not bump global score during second forms sync" + ); + + equal( + collection.count(), + 2, + "New local form should exist on server after second sync" + ); + } finally { + engine._uploadOutgoing = uploadOutgoing; + await cleanup(engine, server); + } +}); + +add_task(async function test_bookmark_change_during_sync() { + _("Ensure that we track bookmark changes made during a sync."); + + enableValidationPrefs(); + let schedulerProto = Object.getPrototypeOf(Service.scheduler); + let syncThresholdDescriptor = Object.getOwnPropertyDescriptor( + schedulerProto, + "syncThreshold" + ); + Object.defineProperty(Service.scheduler, "syncThreshold", { + // Trigger resync if any changes exist, rather than deciding based on the + // normal sync threshold. + get: () => 0, + }); + + let engine = Service.engineManager.get("bookmarks"); + let server = await serverForEnginesWithKeys({ foo: "password" }, [engine]); + await SyncTestingInfrastructure(server); + + // Already-tracked bookmarks that shouldn't be uploaded during the first sync. + let bzBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "https://bugzilla.mozilla.org/", + title: "Bugzilla", + }); + _(`Bugzilla GUID: ${bzBmk.guid}`); + + await PlacesTestUtils.setBookmarkSyncFields({ + guid: bzBmk.guid, + syncChangeCounter: 0, + syncStatus: PlacesUtils.bookmarks.SYNC_STATUS.NORMAL, + }); + + let collection = server.user("foo").collection("bookmarks"); + + let bmk3; // New child of Folder 1, created locally during sync. + + let uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async function () { + engine._uploadOutgoing = uploadOutgoing; + try { + await uploadOutgoing.call(this); + } finally { + _("Inserting bookmark into local store"); + bmk3 = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "https://mozilla.org/", + title: "Mozilla", + }); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + }; + + // New bookmarks that should be uploaded during the first sync. + let folder1 = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Folder 1", + }); + _(`Folder GUID: ${folder1.guid}`); + + let tbBmk = await PlacesUtils.bookmarks.insert({ + parentGuid: folder1.guid, + url: "http://getthunderbird.com/", + title: "Get Thunderbird!", + }); + _(`Thunderbird GUID: ${tbBmk.guid}`); + + engine._tracker.start(); + + try { + let bmk2_guid = "get-firefox1"; // New child of Folder 1, created remotely. + let folder2_guid = "folder2-1111"; // New folder, created remotely. + let tagQuery_guid = "tag-query111"; // New tag query child of Folder 2, created remotely. + let bmk4_guid = "example-org1"; // New tagged child of Folder 2, created remotely. + { + // An existing record changed on the server that should not trigger + // another sync when applied. + let remoteBzBmk = new Bookmark("bookmarks", bzBmk.guid); + remoteBzBmk.bmkUri = "https://bugzilla.mozilla.org/"; + remoteBzBmk.description = "New description"; + remoteBzBmk.title = "Bugzilla"; + remoteBzBmk.tags = ["new", "tags"]; + remoteBzBmk.parentName = "Bookmarks Menu"; + remoteBzBmk.parentid = "menu"; + collection.insert(bzBmk.guid, encryptPayload(remoteBzBmk.cleartext)); + + let remoteFolder = new BookmarkFolder("bookmarks", folder2_guid); + remoteFolder.title = "Folder 2"; + remoteFolder.children = [bmk4_guid, tagQuery_guid]; + remoteFolder.parentName = "Bookmarks Menu"; + remoteFolder.parentid = "menu"; + collection.insert(folder2_guid, encryptPayload(remoteFolder.cleartext)); + + let remoteFxBmk = new Bookmark("bookmarks", bmk2_guid); + remoteFxBmk.bmkUri = "http://getfirefox.com/"; + remoteFxBmk.description = "Firefox is awesome."; + remoteFxBmk.title = "Get Firefox!"; + remoteFxBmk.tags = ["firefox", "awesome", "browser"]; + remoteFxBmk.keyword = "awesome"; + remoteFxBmk.parentName = "Folder 1"; + remoteFxBmk.parentid = folder1.guid; + collection.insert(bmk2_guid, encryptPayload(remoteFxBmk.cleartext)); + + // A tag query referencing a nonexistent tag folder, which we should + // create locally when applying the record. + let remoteTagQuery = new BookmarkQuery("bookmarks", tagQuery_guid); + remoteTagQuery.bmkUri = "place:type=7&folder=999"; + remoteTagQuery.title = "Taggy tags"; + remoteTagQuery.folderName = "taggy"; + remoteTagQuery.parentName = "Folder 2"; + remoteTagQuery.parentid = folder2_guid; + collection.insert( + tagQuery_guid, + encryptPayload(remoteTagQuery.cleartext) + ); + + // A bookmark that should appear in the results for the tag query. + let remoteTaggedBmk = new Bookmark("bookmarks", bmk4_guid); + remoteTaggedBmk.bmkUri = "https://example.org/"; + remoteTaggedBmk.title = "Tagged bookmark"; + remoteTaggedBmk.tags = ["taggy"]; + remoteTaggedBmk.parentName = "Folder 2"; + remoteTaggedBmk.parentid = folder2_guid; + collection.insert(bmk4_guid, encryptPayload(remoteTaggedBmk.cleartext)); + + collection.insert( + "toolbar", + encryptPayload({ + id: "toolbar", + type: "folder", + title: "toolbar", + children: [folder1.guid], + parentName: "places", + parentid: "places", + }) + ); + + collection.insert( + "menu", + encryptPayload({ + id: "menu", + type: "folder", + title: "menu", + children: [bzBmk.guid, folder2_guid], + parentName: "places", + parentid: "places", + }) + ); + + collection.insert( + folder1.guid, + encryptPayload({ + id: folder1.guid, + type: "folder", + title: "Folder 1", + children: [bmk2_guid], + parentName: "toolbar", + parentid: "toolbar", + }) + ); + } + + await assertChildGuids( + folder1.guid, + [tbBmk.guid], + "Folder should have 1 child before first sync" + ); + + let pingsPromise = wait_for_pings(2); + + let changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + Object.keys(changes).sort(), + [folder1.guid, tbBmk.guid, "menu", "mobile", "toolbar", "unfiled"].sort(), + "Should track bookmark and folder created before first sync" + ); + + // Unlike the tests above, we can't use `sync_engine_and_validate_telem` + // because the bookmarks engine will automatically schedule a follow-up + // sync for us. + _("Perform first sync and immediate follow-up sync"); + Service.sync({ engines: ["bookmarks"] }); + + let pings = await pingsPromise; + equal(pings.length, 2, "Should submit two pings"); + ok( + pings.every(p => { + assert_success_ping(p); + return p.syncs.length == 1; + }), + "Should submit 1 sync per ping" + ); + + strictEqual( + Service.scheduler.globalScore, + 0, + "Should reset global score after follow-up sync" + ); + ok(bmk3, "Should insert bookmark during first sync to simulate change"); + ok( + collection.wbo(bmk3.guid), + "Changed bookmark should be uploaded after follow-up sync" + ); + + let bmk2 = await PlacesUtils.bookmarks.fetch({ + guid: bmk2_guid, + }); + ok(bmk2, "Remote bookmark should be applied during first sync"); + { + // We only check child GUIDs, and not their order, because the exact + // order is an implementation detail. + let folder1Children = await PlacesSyncUtils.bookmarks.fetchChildRecordIds( + folder1.guid + ); + deepEqual( + folder1Children.sort(), + [bmk2_guid, tbBmk.guid, bmk3.guid].sort(), + "Folder 1 should have 3 children after first sync" + ); + } + await assertChildGuids( + folder2_guid, + [bmk4_guid, tagQuery_guid], + "Folder 2 should have 2 children after first sync" + ); + let taggedURIs = []; + await PlacesUtils.bookmarks.fetch({ tags: ["taggy"] }, b => + taggedURIs.push(b.url) + ); + equal(taggedURIs.length, 1, "Should have 1 tagged URI"); + equal( + taggedURIs[0].href, + "https://example.org/", + "Synced tagged bookmark should appear in tagged URI list" + ); + + changes = await PlacesSyncUtils.bookmarks.pullChanges(); + deepEqual( + changes, + {}, + "Should have already uploaded changes in follow-up sync" + ); + + // First ping won't include validation data, since we've changed bookmarks + // and `canValidate` will indicate it can't proceed. + let engineData = pings.map(p => { + return p.syncs[0].engines.find(e => e.name == "bookmarks-buffered"); + }); + ok(engineData[0].validation, "Engine should validate after first sync"); + ok(engineData[1].validation, "Engine should validate after second sync"); + } finally { + Object.defineProperty( + schedulerProto, + "syncThreshold", + syncThresholdDescriptor + ); + engine._uploadOutgoing = uploadOutgoing; + await cleanup(engine, server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_enginemanager.js b/src/zen/tests/mochitests/sync/test_enginemanager.js new file mode 100644 index 000000000..3e366be54 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_enginemanager.js @@ -0,0 +1,232 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function PetrolEngine() {} +PetrolEngine.prototype.name = "petrol"; +PetrolEngine.prototype.finalize = async function () {}; + +function DieselEngine() {} +DieselEngine.prototype.name = "diesel"; +DieselEngine.prototype.finalize = async function () {}; + +function DummyEngine() {} +DummyEngine.prototype.name = "dummy"; +DummyEngine.prototype.finalize = async function () {}; + +class ActualEngine extends SyncEngine { + constructor(service) { + super("Actual", service); + } +} + +add_task(async function test_basics() { + _("We start out with a clean slate"); + + let manager = new EngineManager(Service); + + let engines = await manager.getAll(); + Assert.equal(engines.length, 0); + Assert.equal(await manager.get("dummy"), undefined); + + _("Register an engine"); + await manager.register(DummyEngine); + let dummy = await manager.get("dummy"); + Assert.ok(dummy instanceof DummyEngine); + + engines = await manager.getAll(); + Assert.equal(engines.length, 1); + Assert.equal(engines[0], dummy); + + _("Register an already registered engine is ignored"); + await manager.register(DummyEngine); + Assert.equal(await manager.get("dummy"), dummy); + + _("Register multiple engines in one go"); + await manager.register([PetrolEngine, DieselEngine]); + let petrol = await manager.get("petrol"); + let diesel = await manager.get("diesel"); + Assert.ok(petrol instanceof PetrolEngine); + Assert.ok(diesel instanceof DieselEngine); + + engines = await manager.getAll(); + Assert.equal(engines.length, 3); + Assert.notEqual(engines.indexOf(petrol), -1); + Assert.notEqual(engines.indexOf(diesel), -1); + + _("Retrieve multiple engines in one go"); + engines = await manager.get(["dummy", "diesel"]); + Assert.equal(engines.length, 2); + Assert.notEqual(engines.indexOf(dummy), -1); + Assert.notEqual(engines.indexOf(diesel), -1); + + _("getEnabled() only returns enabled engines"); + engines = await manager.getEnabled(); + Assert.equal(engines.length, 0); + + petrol.enabled = true; + engines = await manager.getEnabled(); + Assert.equal(engines.length, 1); + Assert.equal(engines[0], petrol); + + dummy.enabled = true; + diesel.enabled = true; + engines = await manager.getEnabled(); + Assert.equal(engines.length, 3); + + _("getEnabled() returns enabled engines in sorted order"); + petrol.syncPriority = 1; + dummy.syncPriority = 2; + diesel.syncPriority = 3; + + engines = await manager.getEnabled(); + + Assert.deepEqual(engines, [petrol, dummy, diesel]); + + _("Changing the priorities should change the order in getEnabled()"); + + dummy.syncPriority = 4; + + engines = await manager.getEnabled(); + + Assert.deepEqual(engines, [petrol, diesel, dummy]); + + _("Unregister an engine by name"); + await manager.unregister("dummy"); + Assert.equal(await manager.get("dummy"), undefined); + engines = await manager.getAll(); + Assert.equal(engines.length, 2); + Assert.equal(engines.indexOf(dummy), -1); + + _("Unregister an engine by value"); + // manager.unregister() checks for instanceof Engine, so let's make one: + await manager.register(ActualEngine); + let actual = await manager.get("actual"); + Assert.ok(actual instanceof ActualEngine); + Assert.ok(actual instanceof SyncEngine); + + await manager.unregister(actual); + Assert.equal(await manager.get("actual"), undefined); +}); + +class AutoEngine { + constructor(type) { + this.name = "automobile"; + this.type = type; + this.initializeCalled = false; + this.finalizeCalled = false; + this.isActive = false; + } + + async initialize() { + Assert.ok(!this.initializeCalled); + Assert.equal(AutoEngine.current, undefined); + this.initializeCalled = true; + this.isActive = true; + AutoEngine.current = this; + } + + async finalize() { + Assert.equal(AutoEngine.current, this); + Assert.ok(!this.finalizeCalled); + Assert.ok(this.isActive); + this.finalizeCalled = true; + this.isActive = false; + AutoEngine.current = undefined; + } +} + +class GasolineEngine extends AutoEngine { + constructor() { + super("gasoline"); + } +} + +class ElectricEngine extends AutoEngine { + constructor() { + super("electric"); + } +} + +add_task(async function test_alternates() { + let manager = new EngineManager(Service); + let engines = await manager.getAll(); + Assert.equal(engines.length, 0); + + const prefName = "services.sync.engines.automobile.electric"; + Services.prefs.clearUserPref(prefName); + + await manager.registerAlternatives( + "automobile", + prefName, + ElectricEngine, + GasolineEngine + ); + + let gasEngine = manager.get("automobile"); + Assert.equal(gasEngine.type, "gasoline"); + + Assert.ok(gasEngine.isActive); + Assert.ok(gasEngine.initializeCalled); + Assert.ok(!gasEngine.finalizeCalled); + Assert.equal(AutoEngine.current, gasEngine); + + _("Check that setting the controlling pref to false makes no difference"); + Services.prefs.setBoolPref(prefName, false); + Assert.equal(manager.get("automobile"), gasEngine); + Assert.ok(gasEngine.isActive); + Assert.ok(gasEngine.initializeCalled); + Assert.ok(!gasEngine.finalizeCalled); + + _("Even after the call to switchAlternatives"); + await manager.switchAlternatives(); + Assert.equal(manager.get("automobile"), gasEngine); + Assert.ok(gasEngine.isActive); + Assert.ok(gasEngine.initializeCalled); + Assert.ok(!gasEngine.finalizeCalled); + + _("Set the pref to true, we still shouldn't switch yet"); + Services.prefs.setBoolPref(prefName, true); + Assert.equal(manager.get("automobile"), gasEngine); + Assert.ok(gasEngine.isActive); + Assert.ok(gasEngine.initializeCalled); + Assert.ok(!gasEngine.finalizeCalled); + + _("Now we expect to switch from gas to electric"); + await manager.switchAlternatives(); + let elecEngine = manager.get("automobile"); + Assert.equal(elecEngine.type, "electric"); + Assert.ok(elecEngine.isActive); + Assert.ok(elecEngine.initializeCalled); + Assert.ok(!elecEngine.finalizeCalled); + Assert.equal(AutoEngine.current, elecEngine); + + Assert.ok(!gasEngine.isActive); + Assert.ok(gasEngine.finalizeCalled); + + _("Switch back, and ensure we get a new instance that got initialized again"); + Services.prefs.setBoolPref(prefName, false); + await manager.switchAlternatives(); + + // First make sure we deactivated the electric engine as we should + Assert.ok(!elecEngine.isActive); + Assert.ok(elecEngine.initializeCalled); + Assert.ok(elecEngine.finalizeCalled); + + let newGasEngine = manager.get("automobile"); + Assert.notEqual(newGasEngine, gasEngine); + Assert.equal(newGasEngine.type, "gasoline"); + + Assert.ok(newGasEngine.isActive); + Assert.ok(newGasEngine.initializeCalled); + Assert.ok(!newGasEngine.finalizeCalled); + + _("Make sure unregister removes the alt info too"); + await manager.unregister("automobile"); + Assert.equal(manager.get("automobile"), null); + Assert.ok(newGasEngine.finalizeCalled); + Assert.deepEqual(Object.keys(manager._altEngineInfo), []); +}); diff --git a/src/zen/tests/mochitests/sync/test_errorhandler_1.js b/src/zen/tests/mochitests/sync/test_errorhandler_1.js new file mode 100644 index 000000000..f5e96ed44 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_errorhandler_1.js @@ -0,0 +1,335 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); + +const fakeServer = new SyncServer(); +fakeServer.start(); +const fakeServerUrl = "http://localhost:" + fakeServer.port; + +registerCleanupFunction(function () { + return promiseStopServer(fakeServer).finally(() => { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + }); +}); + +let engine; +add_task(async function setup() { + await Service.engineManager.clear(); + await Service.engineManager.register(EHTestsCommon.CatapultEngine); + engine = Service.engineManager.get("catapult"); +}); + +async function clean() { + let promiseLogReset = promiseOneObserver("weave:service:reset-file-log"); + await Service.startOver(); + await promiseLogReset; + Status.resetSync(); + Status.resetBackoff(); + // Move log levels back to trace (startOver will have reversed this), sicne + syncTestLogging(); +} + +add_task(async function test_401_logout() { + enableValidationPrefs(); + + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + await sync_and_validate_telem(); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.ok(Service.isLoggedIn); + + let promiseErrors = new Promise(res => { + Svc.Obs.add("weave:service:sync:error", onSyncError); + function onSyncError() { + _("Got weave:service:sync:error in first sync."); + Svc.Obs.remove("weave:service:sync:error", onSyncError); + + // Wait for the automatic next sync. + Svc.Obs.add("weave:service:login:error", onLoginError); + function onLoginError() { + _("Got weave:service:login:error in second sync."); + Svc.Obs.remove("weave:service:login:error", onLoginError); + res(); + } + } + }); + + // Make sync fail due to login rejected. + await configureIdentity({ username: "janedoe" }, server); + Service._updateCachedURLs(); + + _("Starting first sync."); + await sync_and_validate_telem(ping => { + deepEqual(ping.failureReason, { name: "httperror", code: 401 }); + }); + _("First sync done."); + + await promiseErrors; + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR); + Assert.ok(!Service.isLoggedIn); + + // Clean up. + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_credentials_changed_logout() { + enableValidationPrefs(); + + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + await sync_and_validate_telem(); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.ok(Service.isLoggedIn); + + await EHTestsCommon.generateCredentialsChangedFailure(); + + await sync_and_validate_telem(ping => { + equal(ping.status.sync, CREDENTIALS_CHANGED); + deepEqual(ping.failureReason, { + name: "unexpectederror", + error: "Error: Aborting sync, remote setup failed", + }); + }); + + Assert.equal(Status.sync, CREDENTIALS_CHANGED); + Assert.ok(!Service.isLoggedIn); + + // Clean up. + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_login_non_network_error() { + enableValidationPrefs(); + + // Test non-network errors are reported + // when calling sync + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + Service.identity._syncKeyBundle = null; + + await Service.sync(); + Assert.equal(Status.login, LOGIN_FAILED_NO_PASSPHRASE); + + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_sync_non_network_error() { + enableValidationPrefs(); + + // Test non-network errors are reported + // when calling sync + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + await Service.sync(); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.ok(Service.isLoggedIn); + + await EHTestsCommon.generateCredentialsChangedFailure(); + + await sync_and_validate_telem(ping => { + equal(ping.status.sync, CREDENTIALS_CHANGED); + deepEqual(ping.failureReason, { + name: "unexpectederror", + error: "Error: Aborting sync, remote setup failed", + }); + }); + + Assert.equal(Status.sync, CREDENTIALS_CHANGED); + // If we clean this tick, telemetry won't get the right error + await Async.promiseYield(); + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_login_sync_network_error() { + enableValidationPrefs(); + + // Test network errors are reported when calling sync. + await configureIdentity({ username: "broken.wipe" }); + Service.clusterURL = fakeServerUrl; + + await Service.sync(); + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR); + + await clean(); +}); + +add_task(async function test_sync_network_error() { + enableValidationPrefs(); + + // Test network errors are reported when calling sync. + Services.io.offline = true; + + await Service.sync(); + Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + + Services.io.offline = false; + await clean(); +}); + +add_task(async function test_login_non_network_error() { + enableValidationPrefs(); + + // Test non-network errors are reported + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + Service.identity._syncKeyBundle = null; + + await Service.sync(); + Assert.equal(Status.login, LOGIN_FAILED_NO_PASSPHRASE); + + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_sync_non_network_error() { + enableValidationPrefs(); + + // Test non-network errors are reported + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + // By calling sync, we ensure we're logged in. + await Service.sync(); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.ok(Service.isLoggedIn); + + await EHTestsCommon.generateCredentialsChangedFailure(); + + await Service.sync(); + Assert.equal(Status.sync, CREDENTIALS_CHANGED); + + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_login_network_error() { + enableValidationPrefs(); + + await configureIdentity({ username: "johndoe" }); + Service.clusterURL = fakeServerUrl; + + // Test network errors are not reported. + + await Service.sync(); + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR); + + Services.io.offline = false; + await clean(); +}); + +add_task(async function test_sync_network_error() { + enableValidationPrefs(); + + // Test network errors are not reported. + Services.io.offline = true; + + await Service.sync(); + Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + + Services.io.offline = false; + await clean(); +}); + +add_task(async function test_sync_server_maintenance_error() { + enableValidationPrefs(); + + // Test server maintenance errors are not reported. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + const BACKOFF = 42; + engine.enabled = true; + engine.exception = { status: 503, headers: { "retry-after": BACKOFF } }; + + Assert.equal(Status.service, STATUS_OK); + + await sync_and_validate_telem(ping => { + equal(ping.status.sync, SERVER_MAINTENANCE); + deepEqual(ping.engines.find(e => e.failureReason).failureReason, { + name: "httperror", + code: 503, + }); + }); + + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + Assert.equal(Status.sync, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_info_collections_login_server_maintenance_error() { + enableValidationPrefs(); + + // Test info/collections server maintenance errors are not reported. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "broken.info" }, server); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + await Service.sync(); + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_meta_global_login_server_maintenance_error() { + enableValidationPrefs(); + + // Test meta/global server maintenance errors are not reported. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "broken.meta" }, server); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + await Service.sync(); + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_errorhandler_2.js b/src/zen/tests/mochitests/sync/test_errorhandler_2.js new file mode 100644 index 000000000..2a7e6ba61 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_errorhandler_2.js @@ -0,0 +1,529 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); + +const fakeServer = new SyncServer(); +fakeServer.start(); + +registerCleanupFunction(function () { + return promiseStopServer(fakeServer).finally(() => { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + }); +}); + +const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"]); +logsdir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +function removeLogFiles() { + let entries = logsdir.directoryEntries; + while (entries.hasMoreElements()) { + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + logfile.remove(false); + } +} + +function getLogFiles() { + let result = []; + let entries = logsdir.directoryEntries; + while (entries.hasMoreElements()) { + result.push(entries.getNext().QueryInterface(Ci.nsIFile)); + } + return result; +} + +let engine; +add_task(async function setup() { + await Service.engineManager.clear(); + await Service.engineManager.register(EHTestsCommon.CatapultEngine); + engine = Service.engineManager.get("catapult"); +}); + +async function clean() { + let promiseLogReset = promiseOneObserver("weave:service:reset-file-log"); + await Service.startOver(); + await promiseLogReset; + Status.resetSync(); + Status.resetBackoff(); + removeLogFiles(); + // Move log levels back to trace (startOver will have reversed this), sicne + syncTestLogging(); +} + +add_task(async function test_crypto_keys_login_server_maintenance_error() { + enableValidationPrefs(); + + Status.resetSync(); + // Test crypto/keys server maintenance errors are not reported. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "broken.keys" }, server); + + // Force re-download of keys + Service.collectionKeys.clear(); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); +}); + +add_task(async function test_lastSync_not_updated_on_complete_failure() { + enableValidationPrefs(); + + // Test info/collections prolonged server maintenance errors are reported. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "johndoe" }, server); + + // Do an initial sync that we expect to be successful. + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await sync_and_validate_telem(); + await promiseObserved; + + Assert.equal(Status.service, STATUS_OK); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + let lastSync = Svc.PrefBranch.getStringPref("lastSync"); + + Assert.ok(lastSync); + + // Report server maintenance on info/collections requests + server.registerPathHandler( + "/1.1/johndoe/info/collections", + EHTestsCommon.service_unavailable + ); + + promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await sync_and_validate_telem(() => {}); + await promiseObserved; + + Assert.equal(Status.sync, SERVER_MAINTENANCE); + Assert.equal(Status.service, SYNC_FAILED); + + // We shouldn't update lastSync on complete failure. + Assert.equal(lastSync, Svc.PrefBranch.getStringPref("lastSync")); + + await clean(); + await promiseStopServer(server); +}); + +add_task( + async function test_sync_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test server maintenance errors are reported + // when calling syncAndReportErrors. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + const BACKOFF = 42; + engine.enabled = true; + engine.exception = { status: 503, headers: { "retry-after": BACKOFF } }; + + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + Assert.equal(Status.sync, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); + } +); + +add_task( + async function test_info_collections_login_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test info/collections server maintenance errors are reported + // when calling syncAndReportErrors. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "broken.info" }, server); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); + } +); + +add_task( + async function test_meta_global_login_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test meta/global server maintenance errors are reported + // when calling syncAndReportErrors. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "broken.meta" }, server); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); + } +); + +add_task( + async function test_download_crypto_keys_login_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = await EHTestsCommon.sync_httpd_setup(); + await EHTestsCommon.setUp(server); + + await configureIdentity({ username: "broken.keys" }, server); + // Force re-download of keys + Service.collectionKeys.clear(); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); + } +); + +add_task( + async function test_upload_crypto_keys_login_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = await EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + await configureIdentity({ username: "broken.keys" }, server); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); + } +); + +add_task( + async function test_wipeServer_login_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test crypto/keys server maintenance errors are reported + // when calling syncAndReportErrors. + let server = await EHTestsCommon.sync_httpd_setup(); + + // Start off with an empty account, do not upload a key. + await configureIdentity({ username: "broken.wipe" }, server); + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, LOGIN_FAILED); + Assert.equal(Status.login, SERVER_MAINTENANCE); + + await clean(); + await promiseStopServer(server); + } +); + +add_task( + async function test_wipeRemote_syncAndReportErrors_server_maintenance_error() { + enableValidationPrefs(); + + // Test that we report prolonged server maintenance errors that occur whilst + // wiping all remote devices. + let server = await EHTestsCommon.sync_httpd_setup(); + + await configureIdentity({ username: "broken.wipe" }, server); + await EHTestsCommon.generateAndUploadKeys(); + + engine.exception = null; + engine.enabled = true; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.service, STATUS_OK); + + Svc.PrefBranch.setStringPref("firstSync", "wipeRemote"); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + await Service.sync(); + await promiseObserved; + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Status.service, SYNC_FAILED); + Assert.equal(Status.sync, SERVER_MAINTENANCE); + Assert.equal(Svc.PrefBranch.getStringPref("firstSync"), "wipeRemote"); + + await clean(); + await promiseStopServer(server); + } +); + +add_task(async function test_sync_engine_generic_fail() { + enableValidationPrefs(); + + equal(getLogFiles().length, 0); + + let server = await EHTestsCommon.sync_httpd_setup(); + engine.enabled = true; + engine.sync = async function sync() { + Svc.Obs.notify("weave:engine:sync:error", ENGINE_UNKNOWN_FAIL, "catapult"); + }; + let lastSync = Svc.PrefBranch.getStringPref("lastSync", null); + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + + Assert.equal(Status.engines.catapult, undefined); + + let promiseObserved = new Promise(res => { + Svc.Obs.add("weave:engine:sync:finish", function onEngineFinish() { + Svc.Obs.remove("weave:engine:sync:finish", onEngineFinish); + + log.info("Adding reset-file-log observer."); + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + res(); + }); + }); + }); + + Assert.ok(await EHTestsCommon.setUp(server)); + await sync_and_validate_telem(ping => { + deepEqual(ping.status.service, SYNC_FAILED_PARTIAL); + deepEqual(ping.engines.find(e => e.status).status, ENGINE_UNKNOWN_FAIL); + }); + + await promiseObserved; + + _("Status.engines: " + JSON.stringify(Status.engines)); + Assert.equal(Status.engines.catapult, ENGINE_UNKNOWN_FAIL); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + + // lastSync should update on partial failure. + Assert.notEqual(lastSync, Svc.PrefBranch.getStringPref("lastSync")); + + // Test Error log was written on SYNC_FAILED_PARTIAL. + let logFiles = getLogFiles(); + equal(logFiles.length, 1); + Assert.ok( + logFiles[0].leafName.startsWith("error-sync-"), + logFiles[0].leafName + ); + + await clean(); + + await promiseStopServer(server); +}); + +add_task(async function test_logs_on_sync_error() { + enableValidationPrefs(); + + _( + "Ensure that an error is still logged when weave:service:sync:error " + + "is notified, despite shouldReportError returning false." + ); + + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + log.info("TESTING"); + + // Ensure that we report no error. + Status.login = MASTER_PASSWORD_LOCKED; + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + Svc.Obs.notify("weave:service:sync:error", {}); + await promiseObserved; + + // Test that error log was written. + let logFiles = getLogFiles(); + equal(logFiles.length, 1); + Assert.ok( + logFiles[0].leafName.startsWith("error-sync-"), + logFiles[0].leafName + ); + + await clean(); +}); + +add_task(async function test_logs_on_login_error() { + enableValidationPrefs(); + + _( + "Ensure that an error is still logged when weave:service:login:error " + + "is notified, despite shouldReportError returning false." + ); + + let log = Log.repository.getLogger("Sync.ErrorHandler"); + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + log.info("TESTING"); + + // Ensure that we report no error. + Status.login = MASTER_PASSWORD_LOCKED; + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + Svc.Obs.notify("weave:service:login:error", {}); + await promiseObserved; + + // Test that error log was written. + let logFiles = getLogFiles(); + equal(logFiles.length, 1); + Assert.ok( + logFiles[0].leafName.startsWith("error-sync-"), + logFiles[0].leafName + ); + + await clean(); +}); + +// This test should be the last one since it monkeypatches the engine object +// and we should only have one engine object throughout the file (bug 629664). +add_task(async function test_engine_applyFailed() { + enableValidationPrefs(); + + let server = await EHTestsCommon.sync_httpd_setup(); + + engine.enabled = true; + delete engine.exception; + engine.sync = async function sync() { + Svc.Obs.notify("weave:engine:sync:applied", { newFailed: 1 }, "catapult"); + }; + + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + + let promiseObserved = promiseOneObserver("weave:service:reset-file-log"); + + Assert.equal(Status.engines.catapult, undefined); + Assert.ok(await EHTestsCommon.setUp(server)); + await Service.sync(); + await promiseObserved; + + Assert.equal(Status.engines.catapult, ENGINE_APPLY_FAIL); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + + // Test Error log was written on SYNC_FAILED_PARTIAL. + let logFiles = getLogFiles(); + equal(logFiles.length, 1); + Assert.ok( + logFiles[0].leafName.startsWith("error-sync-"), + logFiles[0].leafName + ); + + await clean(); + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_errorhandler_filelog.js b/src/zen/tests/mochitests/sync/test_errorhandler_filelog.js new file mode 100644 index 000000000..357049276 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_errorhandler_filelog.js @@ -0,0 +1,473 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// `Service` is used as a global in head_helpers.js. +// eslint-disable-next-line no-unused-vars +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { logManager } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); + +const logsdir = FileUtils.getDir("ProfD", ["weave", "logs"]); +logsdir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +// Delay to wait before cleanup, to allow files to age. +// This is so large because the file timestamp granularity is per-second, and +// so otherwise we can end up with all of our files -- the ones we want to +// keep, and the ones we want to clean up -- having the same modified time. +const CLEANUP_DELAY = 2000; +const DELAY_BUFFER = 500; // Buffer for timers on different OS platforms. + +function run_test() { + validate_all_future_pings(); + run_next_test(); +} + +add_test(function test_noOutput() { + // Ensure that the log appender won't print anything. + logManager._fileAppender.level = Log.Level.Fatal + 1; + + // Clear log output from startup. + Svc.PrefBranch.setBoolPref("log.appender.file.logOnSuccess", false); + Svc.Obs.notify("weave:service:sync:finish"); + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLogOuter() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLogOuter); + // Clear again without having issued any output. + Svc.PrefBranch.setBoolPref("log.appender.file.logOnSuccess", true); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLogInner() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLogInner); + + logManager._fileAppender.level = Log.Level.Trace; + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + + // Fake a successful sync. + Svc.Obs.notify("weave:service:sync:finish"); + }); +}); + +add_test(function test_logOnSuccess_false() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnSuccess", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + log.info("this won't show up"); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + Assert.ok(!logsdir.directoryEntries.hasMoreElements()); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + + // Fake a successful sync. + Svc.Obs.notify("weave:service:sync:finish"); +}); + +function readFile(file, callback) { + NetUtil.asyncFetch( + { + uri: NetUtil.newURI(file), + loadUsingSystemPrincipal: true, + }, + function (inputStream, statusCode) { + let data = NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + callback(statusCode, data); + } + ); +} + +add_test(function test_logOnSuccess_true() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnSuccess", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + Assert.equal(logfile.leafName.slice(-4), ".txt"); + Assert.ok(logfile.leafName.startsWith("success-sync-"), logfile.leafName); + Assert.ok(!entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + Assert.ok(Components.isSuccessCode(error)); + Assert.notEqual(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch (ex) { + dump("Couldn't delete file: " + ex.message + "\n"); + // Stupid Windows box. + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + }); + + // Fake a successful sync. + Svc.Obs.notify("weave:service:sync:finish"); +}); + +add_test(function test_sync_error_logOnError_false() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + log.info("this won't show up"); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + Assert.ok(!logsdir.directoryEntries.hasMoreElements()); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + + // Fake an unsuccessful sync. + Svc.Obs.notify("weave:service:sync:error"); +}); + +add_test(function test_sync_error_logOnError_true() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + Assert.equal(logfile.leafName.slice(-4), ".txt"); + Assert.ok(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + Assert.ok(!entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + Assert.ok(Components.isSuccessCode(error)); + Assert.notEqual(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch (ex) { + dump("Couldn't delete file: " + ex.message + "\n"); + // Stupid Windows box. + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + }); + + // Fake an unsuccessful sync. + Svc.Obs.notify("weave:service:sync:error"); +}); + +add_test(function test_login_error_logOnError_false() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + log.info("this won't show up"); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + Assert.ok(!logsdir.directoryEntries.hasMoreElements()); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + + // Fake an unsuccessful login. + Svc.Obs.notify("weave:service:login:error"); +}); + +add_test(function test_login_error_logOnError_true() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + Assert.equal(logfile.leafName.slice(-4), ".txt"); + Assert.ok(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + Assert.ok(!entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + Assert.ok(Components.isSuccessCode(error)); + Assert.notEqual(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch (ex) { + dump("Couldn't delete file: " + ex.message + "\n"); + // Stupid Windows box. + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + }); + + // Fake an unsuccessful login. + Svc.Obs.notify("weave:service:login:error"); +}); + +add_test(function test_noNewFailed_noErrorLog() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + Svc.PrefBranch.setBoolPref("log.appender.file.logOnSuccess", false); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + // No log file was written. + Assert.ok(!logsdir.directoryEntries.hasMoreElements()); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + // failed is nonzero and newFailed is zero -- shouldn't write a log. + let count = { + applied: 8, + succeeded: 4, + failed: 5, + newFailed: 0, + reconciled: 4, + }; + Svc.Obs.notify("weave:engine:sync:applied", count, "foobar-engine"); + Svc.Obs.notify("weave:service:sync:finish"); +}); + +add_test(function test_newFailed_errorLog() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + Svc.PrefBranch.setBoolPref("log.appender.file.logOnSuccess", false); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up 2"; + log.info(MESSAGE); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + // Exactly one log file was written. + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + Assert.equal(logfile.leafName.slice(-4), ".txt"); + Assert.ok(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + Assert.ok(!entries.hasMoreElements()); + + // Ensure the log message was actually written to file. + readFile(logfile, function (error, data) { + Assert.ok(Components.isSuccessCode(error)); + Assert.notEqual(data.indexOf(MESSAGE), -1); + + // Clean up. + try { + logfile.remove(false); + } catch (ex) { + dump("Couldn't delete file: " + ex.message + "\n"); + // Stupid Windows box. + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + }); + // newFailed is nonzero -- should write a log. + let count = { + applied: 8, + succeeded: 4, + failed: 5, + newFailed: 4, + reconciled: 4, + }; + + Svc.Obs.notify("weave:engine:sync:applied", count, "foobar-engine"); + Svc.Obs.notify("weave:service:sync:finish"); +}); + +add_test(function test_errorLog_dumpAddons() { + Svc.PrefBranch.setStringPref("log.logger", "Trace"); + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + + Svc.Obs.add("weave:service:reset-file-log", function onResetFileLog() { + Svc.Obs.remove("weave:service:reset-file-log", onResetFileLog); + + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + Assert.equal(logfile.leafName.slice(-4), ".txt"); + Assert.ok(logfile.leafName.startsWith("error-sync-"), logfile.leafName); + Assert.ok(!entries.hasMoreElements()); + + // Ensure we logged some addon list (which is probably empty) + readFile(logfile, function (error, data) { + Assert.ok(Components.isSuccessCode(error)); + Assert.notEqual(data.indexOf("Addons installed"), -1); + + // Clean up. + try { + logfile.remove(false); + } catch (ex) { + dump("Couldn't delete file: " + ex.message + "\n"); + // Stupid Windows box. + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + }); + }); + + // Fake an unsuccessful sync. + Svc.Obs.notify("weave:service:sync:error"); +}); + +// Check that error log files are deleted above an age threshold. +add_test(async function test_logErrorCleanup_age() { + _("Beginning test_logErrorCleanup_age."); + let maxAge = CLEANUP_DELAY / 1000; + let oldLogs = []; + let numLogs = 10; + let errString = "some error log\n"; + + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + Svc.PrefBranch.setIntPref("log.appender.file.maxErrorAge", maxAge); + + _("Making some files."); + const logsDir = PathUtils.join(PathUtils.profileDir, "weave", "logs"); + await IOUtils.makeDirectory(logsDir); + for (let i = 0; i < numLogs; i++) { + let now = Date.now(); + let filename = "error-sync-" + now + "" + i + ".txt"; + let newLog = new FileUtils.File(PathUtils.join(logsDir, filename)); + let foStream = FileUtils.openFileOutputStream(newLog); + foStream.write(errString, errString.length); + foStream.close(); + _(" > Created " + filename); + oldLogs.push(newLog.leafName); + } + + Svc.Obs.add( + "services-tests:common:log-manager:cleanup-logs", + function onCleanupLogs() { + Svc.Obs.remove( + "services-tests:common:log-manager:cleanup-logs", + onCleanupLogs + ); + + // Only the newest created log file remains. + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + let logfile = entries.getNext().QueryInterface(Ci.nsIFile); + Assert.ok( + oldLogs.every(function (e) { + return e != logfile.leafName; + }) + ); + Assert.ok(!entries.hasMoreElements()); + + // Clean up. + try { + logfile.remove(false); + } catch (ex) { + dump("Couldn't delete file: " + ex.message + "\n"); + // Stupid Windows box. + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + run_next_test(); + } + ); + + let delay = CLEANUP_DELAY + DELAY_BUFFER; + + _("Cleaning up logs after " + delay + "msec."); + CommonUtils.namedTimer( + function onTimer() { + Svc.Obs.notify("weave:service:sync:error"); + }, + delay, + this, + "cleanup-timer" + ); +}); + +add_task(async function test_remove_log_on_startOver() { + Svc.PrefBranch.setBoolPref("log.appender.file.logOnError", true); + + let log = Log.repository.getLogger("Sync.Test.FileLog"); + const MESSAGE = "this WILL show up"; + log.info(MESSAGE); + + let promiseLogWritten = promiseOneObserver("weave:service:reset-file-log"); + // Fake an unsuccessful sync. + Svc.Obs.notify("weave:service:sync:error"); + + await promiseLogWritten; + // Should have at least 1 log file. + let entries = logsdir.directoryEntries; + Assert.ok(entries.hasMoreElements()); + + // Fake a reset. + let promiseRemoved = promiseOneObserver("weave:service:remove-file-log"); + Svc.Obs.notify("weave:service:start-over:finish"); + await promiseRemoved; + + // should be no files left. + Assert.ok(!logsdir.directoryEntries.hasMoreElements()); +}); diff --git a/src/zen/tests/mochitests/sync/test_errorhandler_sync_checkServerError.js b/src/zen/tests/mochitests/sync/test_errorhandler_sync_checkServerError.js new file mode 100644 index 000000000..d73d548cc --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_errorhandler_sync_checkServerError.js @@ -0,0 +1,294 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); +const { FakeCryptoService } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/fakeservices.sys.mjs" +); + +var engineManager = Service.engineManager; + +function CatapultEngine() { + SyncEngine.call(this, "Catapult", Service); +} +CatapultEngine.prototype = { + exception: null, // tests fill this in + async _sync() { + throw this.exception; + }, +}; +Object.setPrototypeOf(CatapultEngine.prototype, SyncEngine.prototype); + +async function sync_httpd_setup() { + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + let catapultEngine = engineManager.get("catapult"); + let syncID = await catapultEngine.resetLocalSyncID(); + let engines = { catapult: { version: catapultEngine.version, syncID } }; + + // Track these using the collections helper, which keeps modified times + // up-to-date. + let clientsColl = new ServerCollection({}, true); + let keysWBO = new ServerWBO("keys"); + let globalWBO = new ServerWBO("global", { + storageVersion: STORAGE_VERSION, + syncID: Utils.makeGUID(), + engines, + }); + + let handlers = { + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/meta/global": upd("meta", globalWBO.handler()), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()), + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + }; + return httpd_setup(handlers); +} + +async function setUp(server) { + await configureIdentity({ username: "johndoe" }, server); + new FakeCryptoService(); + syncTestLogging(); +} + +async function generateAndUploadKeys(server) { + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + let res = Service.resource( + server.baseURI + "/1.1/johndoe/storage/crypto/keys" + ); + return (await serverKeys.upload(res)).success; +} + +add_task(async function setup() { + await engineManager.clear(); + validate_all_future_pings(); + await engineManager.register(CatapultEngine); +}); + +add_task(async function test_backoff500() { + enableValidationPrefs(); + + _("Test: HTTP 500 sets backoff status."); + let server = await sync_httpd_setup(); + await setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = { status: 500 }; + + try { + Assert.ok(!Status.enforceBackoff); + + // Forcibly create and upload keys here -- otherwise we don't get to the 500! + Assert.ok(await generateAndUploadKeys(server)); + + await Service.login(); + await Service.sync(); + Assert.ok(Status.enforceBackoff); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetBackoff(); + await Service.startOver(); + } + await promiseStopServer(server); +}); + +add_task(async function test_backoff503() { + enableValidationPrefs(); + + _( + "Test: HTTP 503 with Retry-After header leads to backoff notification and sets backoff status." + ); + let server = await sync_httpd_setup(); + await setUp(server); + + const BACKOFF = 42; + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = { status: 503, headers: { "retry-after": BACKOFF } }; + + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function (subject) { + backoffInterval = subject; + }); + + try { + Assert.ok(!Status.enforceBackoff); + + Assert.ok(await generateAndUploadKeys(server)); + + await Service.login(); + await Service.sync(); + + Assert.ok(Status.enforceBackoff); + Assert.equal(backoffInterval, BACKOFF); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + Assert.equal(Status.sync, SERVER_MAINTENANCE); + } finally { + Status.resetBackoff(); + Status.resetSync(); + await Service.startOver(); + } + await promiseStopServer(server); +}); + +add_task(async function test_overQuota() { + enableValidationPrefs(); + + _("Test: HTTP 400 with body error code 14 means over quota."); + let server = await sync_httpd_setup(); + await setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = { + status: 400, + toString() { + return "14"; + }, + }; + + try { + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await generateAndUploadKeys(server)); + + await Service.login(); + await Service.sync(); + + Assert.equal(Status.sync, OVER_QUOTA); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetSync(); + await Service.startOver(); + } + await promiseStopServer(server); +}); + +add_task(async function test_service_networkError() { + enableValidationPrefs(); + + _( + "Test: Connection refused error from Service.sync() leads to the right status code." + ); + let server = await sync_httpd_setup(); + await setUp(server); + await promiseStopServer(server); + // Provoke connection refused. + Service.clusterURL = "http://localhost:12345/"; + + try { + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Service._loggedIn = true; + await Service.sync(); + + Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + Assert.equal(Status.service, SYNC_FAILED); + } finally { + Status.resetSync(); + await Service.startOver(); + } +}); + +add_task(async function test_service_offline() { + enableValidationPrefs(); + + _( + "Test: Wanting to sync in offline mode leads to the right status code but does not increment the ignorable error count." + ); + let server = await sync_httpd_setup(); + await setUp(server); + + await promiseStopServer(server); + Services.io.offline = true; + Services.prefs.setBoolPref("network.dns.offline-localhost", false); + + try { + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Service._loggedIn = true; + await Service.sync(); + + Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + Assert.equal(Status.service, SYNC_FAILED); + } finally { + Status.resetSync(); + await Service.startOver(); + } + Services.io.offline = false; + Services.prefs.clearUserPref("network.dns.offline-localhost"); +}); + +add_task(async function test_engine_networkError() { + enableValidationPrefs(); + + _( + "Test: Network related exceptions from engine.sync() lead to the right status code." + ); + let server = await sync_httpd_setup(); + await setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + engine.exception = Components.Exception( + "NS_ERROR_UNKNOWN_HOST", + Cr.NS_ERROR_UNKNOWN_HOST + ); + + try { + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await generateAndUploadKeys(server)); + + await Service.login(); + await Service.sync(); + + Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetSync(); + await Service.startOver(); + } + await promiseStopServer(server); +}); + +add_task(async function test_resource_timeout() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + let engine = engineManager.get("catapult"); + engine.enabled = true; + // Resource throws this when it encounters a timeout. + engine.exception = Components.Exception( + "Aborting due to channel inactivity.", + Cr.NS_ERROR_NET_TIMEOUT + ); + + try { + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await generateAndUploadKeys(server)); + + await Service.login(); + await Service.sync(); + + Assert.equal(Status.sync, LOGIN_FAILED_NETWORK_ERROR); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + } finally { + Status.resetSync(); + await Service.startOver(); + } + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_extension_storage_engine.js b/src/zen/tests/mochitests/sync/test_extension_storage_engine.js new file mode 100644 index 000000000..65194b858 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_extension_storage_engine.js @@ -0,0 +1,259 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Service: "resource://services-sync/service.sys.mjs", + extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.sys.mjs", +}); + +const { ExtensionStorageEngineBridge, ExtensionStorageEngineKinto } = + ChromeUtils.importESModule( + "resource://services-sync/engines/extension-storage.sys.mjs" + ); + +Services.prefs.setStringPref("webextensions.storage.sync.log.level", "debug"); + +add_task(async function test_switching_between_kinto_and_bridged() { + function assertUsingKinto(message) { + let kintoEngine = Service.engineManager.get("extension-storage"); + Assert.ok(kintoEngine instanceof ExtensionStorageEngineKinto, message); + } + function assertUsingBridged(message) { + let bridgedEngine = Service.engineManager.get("extension-storage"); + Assert.ok(bridgedEngine instanceof ExtensionStorageEngineBridge, message); + } + + let isUsingKinto = Services.prefs.getBoolPref( + "webextensions.storage.sync.kinto", + false + ); + if (isUsingKinto) { + assertUsingKinto("Should use Kinto engine before flipping pref"); + } else { + assertUsingBridged("Should use bridged engine before flipping pref"); + } + + _("Flip pref"); + Services.prefs.setBoolPref("webextensions.storage.sync.kinto", !isUsingKinto); + await Service.engineManager.switchAlternatives(); + + if (isUsingKinto) { + assertUsingBridged("Should use bridged engine after flipping pref"); + } else { + assertUsingKinto("Should use Kinto engine after flipping pref"); + } + + _("Clean up"); + Services.prefs.clearUserPref("webextensions.storage.sync.kinto"); + await Service.engineManager.switchAlternatives(); +}); + +add_task(async function test_enable() { + const PREF = "services.sync.engine.extension-storage.force"; + + let addonsEngine = Service.engineManager.get("addons"); + let extensionStorageEngine = Service.engineManager.get("extension-storage"); + + try { + Assert.ok( + addonsEngine.enabled, + "Add-ons engine should be enabled by default" + ); + Assert.ok( + extensionStorageEngine.enabled, + "Extension storage engine should be enabled by default" + ); + + addonsEngine.enabled = false; + Assert.ok( + !extensionStorageEngine.enabled, + "Disabling add-ons should disable extension storage" + ); + + extensionStorageEngine.enabled = true; + Assert.ok( + !extensionStorageEngine.enabled, + "Enabling extension storage without override pref shouldn't work" + ); + + Services.prefs.setBoolPref(PREF, true); + Assert.ok( + extensionStorageEngine.enabled, + "Setting override pref should enable extension storage" + ); + + extensionStorageEngine.enabled = false; + Assert.ok( + !extensionStorageEngine.enabled, + "Disabling extension storage engine with override pref should work" + ); + + extensionStorageEngine.enabled = true; + Assert.ok( + extensionStorageEngine.enabled, + "Enabling extension storage with override pref should work" + ); + } finally { + addonsEngine.enabled = true; + Services.prefs.clearUserPref(PREF); + } +}); + +add_task(async function test_notifyPendingChanges() { + let engine = new ExtensionStorageEngineBridge(Service); + await engine.initialize(); + + let extension = { id: "ext-1" }; + let expectedChange = { + a: "b", + c: "d", + }; + + let lastSync = 0; + let syncID = Utils.makeGUID(); + let error = null; + engine._rustStore = { + getSyncedChanges() { + if (error) { + throw new Error(error.message); + } else { + return [ + { extId: extension.id, changes: JSON.stringify(expectedChange) }, + ]; + } + }, + }; + + engine._bridge = { + ensureCurrentSyncId(id) { + if (syncID != id) { + syncID = id; + lastSync = 0; + } + return id; + }, + resetSyncId() { + return syncID; + }, + syncStarted() {}, + lastSync() { + return lastSync; + }, + setLastSync(lastSyncMillis) { + lastSync = lastSyncMillis; + }, + apply() { + return []; + }, + setUploaded(_modified, _ids) {}, + syncFinished() {}, + }; + + let server = await serverForFoo(engine); + + let actualChanges = []; + let listener = changes => actualChanges.push(changes); + extensionStorageSync.addOnChangedListener(extension, listener); + + try { + await SyncTestingInfrastructure(server); + + info("Sync engine; notify about changes"); + await sync_engine_and_validate_telem(engine, false); + deepEqual( + actualChanges, + [expectedChange], + "Should notify about changes during sync" + ); + + error = new Error("oops!"); + actualChanges = []; + await sync_engine_and_validate_telem(engine, false); + deepEqual( + actualChanges, + [], + "Should finish syncing even if notifying about changes fails" + ); + } finally { + extensionStorageSync.removeOnChangedListener(extension, listener); + await promiseStopServer(server); + await engine.finalize(); + } +}); + +// It's difficult to know what to test - there's already tests for the bridged +// engine etc - so we just try and check that this engine conforms to the +// mozIBridgedSyncEngine interface guarantees. +add_task(async function test_engine() { + // Forcibly set the bridged engine in the engine manager. the reason we do + // this, unlike the other tests where we just create the engine, is so that + // telemetry can get at the engine's `overrideTelemetryName`, which it gets + // through the engine manager. + await Service.engineManager.unregister("extension-storage"); + await Service.engineManager.register(ExtensionStorageEngineBridge); + let engine = Service.engineManager.get("extension-storage"); + Assert.equal(engine.version, 1); + + Assert.deepEqual(await engine.getSyncID(), null); + await engine.resetLocalSyncID(); + Assert.notEqual(await engine.getSyncID(), null); + + Assert.equal(await engine.getLastSync(), 0); + // lastSync is seconds on this side of the world, but milli-seconds on the other. + await engine.setLastSync(1234.567); + // should have 2 digit precision. + Assert.equal(await engine.getLastSync(), 1234.57); + await engine.setLastSync(0); + + // Set some data. + await extensionStorageSync.set({ id: "ext-2" }, { ext_2_key: "ext_2_value" }); + // Now do a sync with out regular test server. + let server = await serverForFoo(engine); + try { + await SyncTestingInfrastructure(server); + + info("Add server records"); + let foo = server.user("foo"); + let collection = foo.collection("extension-storage"); + let now = new_timestamp(); + + collection.insert( + "fakeguid0000", + encryptPayload({ + id: "fakeguid0000", + extId: "ext-1", + data: JSON.stringify({ foo: "bar" }), + }), + now + ); + + info("Sync the engine"); + + let ping = await sync_engine_and_validate_telem(engine, false); + Assert.ok(ping.engines.find(e => e.name == "rust-webext-storage")); + Assert.equal( + ping.engines.find(e => e.name == "extension-storage"), + null + ); + + // We should have applied the data from the existing collection record. + Assert.deepEqual(await extensionStorageSync.get({ id: "ext-1" }, null), { + foo: "bar", + }); + + // should now be 2 records on the server. + let payloads = collection.payloads(); + Assert.equal(payloads.length, 2); + // find the new one we wrote. + let newPayload = + payloads[0].id == "fakeguid0000" ? payloads[1] : payloads[0]; + Assert.equal(newPayload.data, `{"ext_2_key":"ext_2_value"}`); + // should have updated the timestamp. + greater(await engine.getLastSync(), 0, "Should update last sync time"); + } finally { + await promiseStopServer(server); + await engine.finalize(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_extension_storage_engine_kinto.js b/src/zen/tests/mochitests/sync/test_extension_storage_engine_kinto.js new file mode 100644 index 000000000..1564e0b79 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_extension_storage_engine_kinto.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +const { ExtensionStorageEngineKinto: ExtensionStorageEngine } = + ChromeUtils.importESModule( + "resource://services-sync/engines/extension-storage.sys.mjs" + ); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { extensionStorageSyncKinto: extensionStorageSync } = + ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" + ); + +let engine; + +function mock(options) { + let calls = []; + let ret = function () { + calls.push(arguments); + return options.returns; + }; + let proto = { + get calls() { + return calls; + }, + }; + Object.setPrototypeOf(proto, Function.prototype); + Object.setPrototypeOf(ret, proto); + return ret; +} + +function setSkipChance(v) { + Services.prefs.setIntPref( + "services.sync.extension-storage.skipPercentageChance", + v + ); +} + +add_task(async function setup() { + await Service.engineManager.register(ExtensionStorageEngine); + engine = Service.engineManager.get("extension-storage"); + do_get_profile(); // so we can use FxAccounts + loadWebExtensionTestFunctions(); + setSkipChance(0); +}); + +add_task(async function test_calling_sync_calls__sync() { + let oldSync = ExtensionStorageEngine.prototype._sync; + let syncMock = (ExtensionStorageEngine.prototype._sync = mock({ + returns: true, + })); + try { + // I wanted to call the main sync entry point for the entire + // package, but that fails because it tries to sync ClientEngine + // first, which fails. + await engine.sync(); + } finally { + ExtensionStorageEngine.prototype._sync = oldSync; + } + equal(syncMock.calls.length, 1); +}); + +add_task(async function test_sync_skip() { + try { + // Do a few times to ensure we aren't getting "lucky" WRT Math.random() + for (let i = 0; i < 10; ++i) { + setSkipChance(100); + engine._tracker._score = 0; + ok( + !engine.shouldSkipSync("user"), + "Should allow explicitly requested syncs" + ); + ok(!engine.shouldSkipSync("startup"), "Should allow startup syncs"); + ok( + engine.shouldSkipSync("schedule"), + "Should skip scheduled syncs if skipProbability is 100" + ); + engine._tracker._score = MULTI_DEVICE_THRESHOLD; + ok( + !engine.shouldSkipSync("schedule"), + "should allow scheduled syncs if tracker score is high" + ); + engine._tracker._score = 0; + setSkipChance(0); + ok( + !engine.shouldSkipSync("schedule"), + "Should allow scheduled syncs if probability is 0" + ); + } + } finally { + engine._tracker._score = 0; + setSkipChance(0); + } +}); + +add_task(async function test_calling_wipeClient_calls_clearAll() { + let oldClearAll = extensionStorageSync.clearAll; + let clearMock = (extensionStorageSync.clearAll = mock({ + returns: Promise.resolve(), + })); + try { + await engine.wipeClient(); + } finally { + extensionStorageSync.clearAll = oldClearAll; + } + equal(clearMock.calls.length, 1); +}); + +add_task(async function test_calling_sync_calls_ext_storage_sync() { + const extension = { id: "my-extension" }; + let oldSync = extensionStorageSync.syncAll; + let syncMock = (extensionStorageSync.syncAll = mock({ + returns: Promise.resolve(), + })); + try { + await withContext(async function (context) { + // Set something so that everyone knows that we're using storage.sync + await extensionStorageSync.set(extension, { a: "b" }, context); + let ping = await sync_engine_and_validate_telem(engine, false); + Assert.ok(ping.engines.find(e => e.name == "extension-storage")); + Assert.equal( + ping.engines.find(e => e.name == "rust-webext-storage"), + null + ); + }); + } finally { + extensionStorageSync.syncAll = oldSync; + } + Assert.greaterOrEqual(syncMock.calls.length, 1); +}); diff --git a/src/zen/tests/mochitests/sync/test_extension_storage_tracker_kinto.js b/src/zen/tests/mochitests/sync/test_extension_storage_tracker_kinto.js new file mode 100644 index 000000000..f09768287 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_extension_storage_tracker_kinto.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +const { ExtensionStorageEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/extension-storage.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { extensionStorageSyncKinto: extensionStorageSync } = + ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" + ); + +let engine; + +add_task(async function setup() { + await Service.engineManager.register(ExtensionStorageEngine); + engine = Service.engineManager.get("extension-storage"); + do_get_profile(); // so we can use FxAccounts + loadWebExtensionTestFunctions(); +}); + +add_task(async function test_changing_extension_storage_changes_score() { + const tracker = engine._tracker; + const extension = { id: "my-extension-id" }; + tracker.start(); + await withContext(async function (context) { + await extensionStorageSync.set(extension, { a: "b" }, context); + }); + Assert.equal(tracker.score, SCORE_INCREMENT_MEDIUM); + + tracker.resetScore(); + await withContext(async function (context) { + await extensionStorageSync.remove(extension, "a", context); + }); + Assert.equal(tracker.score, SCORE_INCREMENT_MEDIUM); + + await tracker.stop(); +}); diff --git a/src/zen/tests/mochitests/sync/test_form_validator.js b/src/zen/tests/mochitests/sync/test_form_validator.js new file mode 100644 index 000000000..58ea8b855 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_form_validator.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { FormValidator } = ChromeUtils.importESModule( + "resource://services-sync/engines/forms.sys.mjs" +); + +function getDummyServerAndClient() { + return { + server: [ + { + id: "11111", + guid: "11111", + name: "foo", + fieldname: "foo", + value: "bar", + }, + { + id: "22222", + guid: "22222", + name: "foo2", + fieldname: "foo2", + value: "bar2", + }, + { + id: "33333", + guid: "33333", + name: "foo3", + fieldname: "foo3", + value: "bar3", + }, + ], + client: [ + { + id: "11111", + guid: "11111", + name: "foo", + fieldname: "foo", + value: "bar", + }, + { + id: "22222", + guid: "22222", + name: "foo2", + fieldname: "foo2", + value: "bar2", + }, + { + id: "33333", + guid: "33333", + name: "foo3", + fieldname: "foo3", + value: "bar3", + }, + ], + }; +} + +add_task(async function test_valid() { + let { server, client } = getDummyServerAndClient(); + let validator = new FormValidator(); + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + equal(clientRecords.length, 3); + equal(records.length, 3); + equal(deletedRecords.length, 0); + deepEqual(problemData, validator.emptyProblemData()); +}); + +add_task(async function test_formValidatorIgnoresMissingClients() { + // Since history form records are not deleted from the server, the + // |FormValidator| shouldn't set the |missingClient| flag in |problemData|. + let { server, client } = getDummyServerAndClient(); + client.pop(); + + let validator = new FormValidator(); + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 2); + equal(records.length, 3); + equal(deletedRecords.length, 0); + + let expected = validator.emptyProblemData(); + deepEqual(problemData, expected); +}); diff --git a/src/zen/tests/mochitests/sync/test_forms_store.js b/src/zen/tests/mochitests/sync/test_forms_store.js new file mode 100644 index 000000000..716487865 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_forms_store.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_( + "Make sure the form store follows the Store api and correctly accesses the backend form storage" +); +const { FormEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/forms.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { SyncedRecordsTelemetry } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" +); + +add_task(async function run_test() { + let engine = new FormEngine(Service); + await engine.initialize(); + let store = engine._store; + + async function applyEnsureNoFailures(records) { + let countTelemetry = new SyncedRecordsTelemetry(); + Assert.equal( + (await store.applyIncomingBatch(records, countTelemetry)).length, + 0 + ); + } + + _("Remove any existing entries"); + await store.wipe(); + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + + _("Add a form entry"); + await applyEnsureNoFailures([ + { + id: Utils.makeGUID(), + name: "name!!", + value: "value??", + }, + ]); + + _("Should have 1 entry now"); + let id = ""; + for (let _id in await store.getAllIDs()) { + if (id == "") { + id = _id; + } else { + do_throw("Should have only gotten one!"); + } + } + Assert.ok(store.itemExists(id)); + + _("Should be able to find this entry as a dupe"); + Assert.equal( + await engine._findDupe({ name: "name!!", value: "value??" }), + id + ); + + let rec = await store.createRecord(id); + _("Got record for id", id, rec); + Assert.equal(rec.name, "name!!"); + Assert.equal(rec.value, "value??"); + + _("Create a non-existent id for delete"); + Assert.ok((await store.createRecord("deleted!!")).deleted); + + _("Try updating.. doesn't do anything yet"); + await store.update({}); + + _("Remove all entries"); + await store.wipe(); + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + + _("Add another entry"); + await applyEnsureNoFailures([ + { + id: Utils.makeGUID(), + name: "another", + value: "entry", + }, + ]); + id = ""; + for (let _id in await store.getAllIDs()) { + if (id == "") { + id = _id; + } else { + do_throw("Should have only gotten one!"); + } + } + + _("Change the id of the new entry to something else"); + await store.changeItemID(id, "newid"); + + _("Make sure it's there"); + Assert.ok(store.itemExists("newid")); + + _("Remove the entry"); + await store.remove({ + id: "newid", + }); + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + + _("Removing the entry again shouldn't matter"); + await store.remove({ + id: "newid", + }); + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + + _("Add another entry to delete using applyIncomingBatch"); + let toDelete = { + id: Utils.makeGUID(), + name: "todelete", + value: "entry", + }; + await applyEnsureNoFailures([toDelete]); + id = ""; + for (let _id in await store.getAllIDs()) { + if (id == "") { + id = _id; + } else { + do_throw("Should have only gotten one!"); + } + } + Assert.ok(store.itemExists(id)); + // mark entry as deleted + toDelete.id = id; + toDelete.deleted = true; + await applyEnsureNoFailures([toDelete]); + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + + _("Add an entry to wipe"); + await applyEnsureNoFailures([ + { + id: Utils.makeGUID(), + name: "towipe", + value: "entry", + }, + ]); + + await store.wipe(); + + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + + _("Ensure we work if formfill is disabled."); + Services.prefs.setBoolPref("browser.formfill.enable", false); + try { + // a search + if ((await store.getAllIDs()).length) { + do_throw("Shouldn't get any ids!"); + } + // an update. + await applyEnsureNoFailures([ + { + id: Utils.makeGUID(), + name: "some", + value: "entry", + }, + ]); + } finally { + Services.prefs.clearUserPref("browser.formfill.enable"); + await store.wipe(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_forms_tracker.js b/src/zen/tests/mochitests/sync/test_forms_tracker.js new file mode 100644 index 000000000..aee74381a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_forms_tracker.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { FormEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/forms.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function run_test() { + _("Verify we've got an empty tracker to work with."); + let engine = new FormEngine(Service); + await engine.initialize(); + let tracker = engine._tracker; + + let changes = await tracker.getChangedIDs(); + do_check_empty(changes); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + async function addEntry(name, value) { + await engine._store.create({ name, value }); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + async function removeEntry(name, value) { + let guid = await engine._findDupe({ name, value }); + await engine._store.remove({ id: guid }); + await engine._tracker.asyncObserver.promiseObserversComplete(); + } + + try { + _("Create an entry. Won't show because we haven't started tracking yet"); + await addEntry("name", "John Doe"); + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + + _("Tell the tracker to start tracking changes."); + tracker.start(); + await removeEntry("name", "John Doe"); + await addEntry("email", "john@doe.com"); + changes = await tracker.getChangedIDs(); + do_check_attribute_count(changes, 2); + + _("Notifying twice won't do any harm."); + tracker.start(); + await addEntry("address", "Memory Lane"); + changes = await tracker.getChangedIDs(); + do_check_attribute_count(changes, 3); + + _("Check that ignoreAll is respected"); + await tracker.clearChangedIDs(); + tracker.score = 0; + tracker.ignoreAll = true; + await addEntry("username", "johndoe123"); + await addEntry("favoritecolor", "green"); + await removeEntry("name", "John Doe"); + tracker.ignoreAll = false; + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + equal(tracker.score, 0); + + _("Let's stop tracking again."); + await tracker.clearChangedIDs(); + await tracker.stop(); + await removeEntry("address", "Memory Lane"); + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + + _("Notifying twice won't do any harm."); + await tracker.stop(); + await removeEntry("email", "john@doe.com"); + changes = await tracker.getChangedIDs(); + do_check_empty(changes); + } finally { + _("Clean up."); + await engine._store.wipe(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_fxa_node_reassignment.js b/src/zen/tests/mochitests/sync/test_fxa_node_reassignment.js new file mode 100644 index 000000000..0fa7ee922 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_fxa_node_reassignment.js @@ -0,0 +1,399 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_("Test that node reassignment happens correctly using the FxA identity mgr."); +// The node-reassignment logic is quite different for FxA than for the legacy +// provider. In particular, there's no special request necessary for +// reassignment - it comes from the token server - so we need to ensure the +// Fxa cluster manager grabs a new token. + +const { RESTRequest } = ChromeUtils.importESModule( + "resource://services-common/rest.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); +const { SyncAuthManager } = ChromeUtils.importESModule( + "resource://services-sync/sync_auth.sys.mjs" +); + +add_task(async function setup() { + // Disables all built-in engines. Important for avoiding errors thrown by the + // add-ons engine. + await Service.engineManager.clear(); + + // Setup the sync auth manager. + Status.__authManager = Service.identity = new SyncAuthManager(); +}); + +// API-compatible with SyncServer handler. Bind `handler` to something to use +// as a ServerCollection handler. +function handleReassign(handler, req, resp) { + resp.setStatusLine(req.httpVersion, 401, "Node reassignment"); + resp.setHeader("Content-Type", "application/json"); + let reassignBody = JSON.stringify({ error: "401inator in place" }); + resp.bodyOutputStream.write(reassignBody, reassignBody.length); +} + +var numTokenRequests = 0; + +function prepareServer(cbAfterTokenFetch) { + syncTestLogging(); + let config = makeIdentityConfig({ username: "johndoe" }); + // A server callback to ensure we don't accidentally hit the wrong endpoint + // after a node reassignment. + let callback = { + onRequest(req) { + let full = `${req.scheme}://${req.host}:${req.port}${req.path}`; + let expected = config.fxaccount.token.endpoint; + Assert.ok( + full.startsWith(expected), + `request made to ${full}, expected ${expected}` + ); + }, + }; + Object.setPrototypeOf(callback, SyncServerCallback); + let server = new SyncServer(callback); + server.registerUser("johndoe"); + server.start(); + + // Set the token endpoint for the initial token request that's done implicitly + // via configureIdentity. + config.fxaccount.token.endpoint = server.baseURI + "1.1/johndoe/"; + // And future token fetches will do magic around numReassigns. + let numReassigns = 0; + return configureIdentity(config).then(() => { + Service.identity._tokenServerClient = { + getTokenUsingOAuth() { + return new Promise(res => { + // Build a new URL with trailing zeros for the SYNC_VERSION part - this + // will still be seen as equivalent by the test server, but different + // by sync itself. + numReassigns += 1; + let trailingZeros = new Array(numReassigns + 1).join("0"); + let token = config.fxaccount.token; + token.endpoint = server.baseURI + "1.1" + trailingZeros + "/johndoe"; + token.uid = config.username; + _(`test server saw token fetch - endpoint now ${token.endpoint}`); + numTokenRequests += 1; + res(token); + if (cbAfterTokenFetch) { + cbAfterTokenFetch(); + } + }); + }, + }; + return server; + }); +} + +function getReassigned() { + try { + return Services.prefs.getBoolPref("services.sync.lastSyncReassigned"); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_UNEXPECTED) { + do_throw( + "Got exception retrieving lastSyncReassigned: " + Log.exceptionStr(ex) + ); + } + } + return false; +} + +/** + * Make a test request to `url`, then watch the result of two syncs + * to ensure that a node request was made. + * Runs `between` between the two. This can be used to undo deliberate failure + * setup, detach observers, etc. + */ +async function syncAndExpectNodeReassignment( + server, + firstNotification, + between, + secondNotification, + url +) { + _("Starting syncAndExpectNodeReassignment\n"); + let deferred = Promise.withResolvers(); + async function onwards() { + let numTokenRequestsBefore; + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + Assert.equal(Service.clusterURL, ""); + + // Track whether we fetched a new token. + numTokenRequestsBefore = numTokenRequests; + + // Allow for tests to clean up error conditions. + between(); + } + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Second sync nextTick."); + Assert.equal( + numTokenRequests, + numTokenRequestsBefore + 1, + "fetched a new token" + ); + Service.startOver().then(() => { + server.stop(deferred.resolve); + }); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + await Service.sync(); + } + + // Make sure that we really do get a 401 (but we can only do that if we are + // already logged in, as the login process is what sets up the URLs) + if (Service.isLoggedIn) { + _("Making request to " + url + " which should 401"); + let request = new RESTRequest(url); + await request.get(); + Assert.equal(request.response.status, 401); + CommonUtils.nextTick(onwards); + } else { + _("Skipping preliminary validation check for a 401 as we aren't logged in"); + CommonUtils.nextTick(onwards); + } + await deferred.promise; +} + +// Check that when we sync we don't request a new token by default - our +// test setup has configured the client with a valid token, and that token +// should be used to form the cluster URL. +add_task(async function test_single_token_fetch() { + enableValidationPrefs(); + + _("Test a normal sync only fetches 1 token"); + + let numTokenFetches = 0; + + function afterTokenFetch() { + numTokenFetches++; + } + + // Set the cluster URL to an "old" version - this is to ensure we don't + // use that old cached version for the first sync but prefer the value + // we got from the token (and as above, we are also checking we don't grab + // a new token). If the test actually attempts to connect to this URL + // it will crash. + Service.clusterURL = "http://example.com/"; + + let server = await prepareServer(afterTokenFetch); + + Assert.ok(!Service.isLoggedIn, "not already logged in"); + await Service.sync(); + Assert.equal(Status.sync, SYNC_SUCCEEDED, "sync succeeded"); + Assert.equal(numTokenFetches, 0, "didn't fetch a new token"); + // A bit hacky, but given we know how prepareServer works we can deduce + // that clusterURL we expect. + let expectedClusterURL = server.baseURI + "1.1/johndoe/"; + Assert.equal(Service.clusterURL, expectedClusterURL); + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_momentary_401_engine() { + enableValidationPrefs(); + + _("Test a failure for engine URLs that's resolved by reassignment."); + let server = await prepareServer(); + let john = server.user("johndoe"); + + _("Enabling the Rotary engine."); + let { engine, syncID, tracker } = await registerRotaryEngine(); + + // We need the server to be correctly set up prior to experimenting. Do this + // through a sync. + let global = { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + rotary: { version: engine.version, syncID }, + }; + john.createCollection("meta").insert("global", global); + + _("First sync to prepare server contents."); + await Service.sync(); + + _("Setting up Rotary collection to 401."); + let rotary = john.createCollection("rotary"); + let oldHandler = rotary.collectionHandler; + rotary.collectionHandler = handleReassign.bind(this, undefined); + + // We want to verify that the clusterURL pref has been cleared after a 401 + // inside a sync. Flag the Rotary engine to need syncing. + john.collection("rotary").timestamp += 1000; + + function between() { + _("Undoing test changes."); + rotary.collectionHandler = oldHandler; + + function onLoginStart() { + // lastSyncReassigned shouldn't be cleared until a sync has succeeded. + _("Ensuring that lastSyncReassigned is still set at next sync start."); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + Assert.ok(getReassigned()); + } + + _("Adding observer that lastSyncReassigned is still set on login."); + Svc.Obs.add("weave:service:login:start", onLoginStart); + } + + await syncAndExpectNodeReassignment( + server, + "weave:service:sync:finish", + between, + "weave:service:sync:finish", + Service.storageURL + "rotary" + ); + + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); +}); + +// This test ends up being a failing info fetch *after we're already logged in*. +add_task(async function test_momentary_401_info_collections_loggedin() { + enableValidationPrefs(); + + _( + "Test a failure for info/collections after login that's resolved by reassignment." + ); + let server = await prepareServer(); + + _("First sync to prepare server contents."); + await Service.sync(); + + _("Arrange for info/collections to return a 401."); + let oldHandler = server.toplevelHandlers.info; + server.toplevelHandlers.info = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.info = oldHandler; + } + + Assert.ok(Service.isLoggedIn, "already logged in"); + + await syncAndExpectNodeReassignment( + server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.infoURL + ); +}); + +// This test ends up being a failing info fetch *before we're logged in*. +// In this case we expect to recover during the login phase - so the first +// sync succeeds. +add_task(async function test_momentary_401_info_collections_loggedout() { + enableValidationPrefs(); + + _( + "Test a failure for info/collections before login that's resolved by reassignment." + ); + + let oldHandler; + let sawTokenFetch = false; + + function afterTokenFetch() { + // After a single token fetch, we undo our evil handleReassign hack, so + // the next /info request returns the collection instead of a 401 + server.toplevelHandlers.info = oldHandler; + sawTokenFetch = true; + } + + let server = await prepareServer(afterTokenFetch); + + // Return a 401 for the next /info request - it will be reset immediately + // after a new token is fetched. + oldHandler = server.toplevelHandlers.info; + server.toplevelHandlers.info = handleReassign; + + Assert.ok(!Service.isLoggedIn, "not already logged in"); + + await Service.sync(); + Assert.equal(Status.sync, SYNC_SUCCEEDED, "sync succeeded"); + // sync was successful - check we grabbed a new token. + Assert.ok(sawTokenFetch, "a new token was fetched by this test."); + // and we are done. + await Service.startOver(); + await promiseStopServer(server); +}); + +// This test ends up being a failing meta/global fetch *after we're already logged in*. +add_task(async function test_momentary_401_storage_loggedin() { + enableValidationPrefs(); + + _( + "Test a failure for any storage URL after login that's resolved by" + + "reassignment." + ); + let server = await prepareServer(); + + _("First sync to prepare server contents."); + await Service.sync(); + + _("Arrange for meta/global to return a 401."); + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + Assert.ok(Service.isLoggedIn, "already logged in"); + + await syncAndExpectNodeReassignment( + server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global" + ); +}); + +// This test ends up being a failing meta/global fetch *before we've logged in*. +add_task(async function test_momentary_401_storage_loggedout() { + enableValidationPrefs(); + + _( + "Test a failure for any storage URL before login, not just engine parts. " + + "Resolved by reassignment." + ); + let server = await prepareServer(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + Assert.ok(!Service.isLoggedIn, "already logged in"); + + await syncAndExpectNodeReassignment( + server, + "weave:service:login:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global" + ); +}); diff --git a/src/zen/tests/mochitests/sync/test_fxa_service_cluster.js b/src/zen/tests/mochitests/sync/test_fxa_service_cluster.js new file mode 100644 index 000000000..0203d01ef --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_fxa_service_cluster.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { initializeIdentityWithTokenServerResponse } = + ChromeUtils.importESModule( + "resource://testing-common/services/sync/fxa_utils.sys.mjs" + ); + +add_task(async function test_findCluster() { + _("Test FxA _findCluster()"); + + _("_findCluster() throws on 500 errors."); + initializeIdentityWithTokenServerResponse({ + status: 500, + headers: [], + body: "", + }); + + await Assert.rejects( + Service.identity._findCluster(), + /TokenServerClientServerError/ + ); + + _("_findCluster() returns null on authentication errors."); + initializeIdentityWithTokenServerResponse({ + status: 401, + headers: { "content-type": "application/json" }, + body: "{}", + }); + + let cluster = await Service.identity._findCluster(); + Assert.strictEqual(cluster, null); + + _("_findCluster() works with correct tokenserver response."); + let endpoint = "http://example.com/something"; + initializeIdentityWithTokenServerResponse({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + api_endpoint: endpoint, + duration: 300, + id: "id", + key: "key", + uid: "uid", + }), + }); + + cluster = await Service.identity._findCluster(); + // The cluster manager ensures a trailing "/" + Assert.strictEqual(cluster, endpoint + "/"); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_glean.js b/src/zen/tests/mochitests/sync/test_glean.js new file mode 100644 index 000000000..ffa768ffa --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_glean.js @@ -0,0 +1,1387 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { RotaryEngine } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/rotaryengine.sys.mjs" +); +const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const fxAccounts = getFxAccountsSingleton(); + +function SteamStore(engine) { + Store.call(this, "Steam", engine); +} +Object.setPrototypeOf(SteamStore.prototype, Store.prototype); + +function SteamTracker(name, engine) { + LegacyTracker.call(this, name || "Steam", engine); +} +Object.setPrototypeOf(SteamTracker.prototype, LegacyTracker.prototype); + +function SteamEngine(service) { + SyncEngine.call(this, "steam", service); +} + +SteamEngine.prototype = { + _storeObj: SteamStore, + _trackerObj: SteamTracker, + _errToThrow: null, + problemsToReport: null, + async _sync() { + if (this._errToThrow) { + throw this._errToThrow; + } + }, + getValidator() { + return new SteamValidator(); + }, +}; +Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype); + +function BogusEngine(service) { + SyncEngine.call(this, "bogus", service); +} + +BogusEngine.prototype = Object.create(SteamEngine.prototype); + +class SteamValidator { + async canValidate() { + return true; + } + + async validate(engine) { + return { + problems: new SteamValidationProblemData(engine.problemsToReport), + version: 1, + duration: 0, + recordCount: 0, + }; + } +} + +class SteamValidationProblemData { + constructor(problemsToReport = []) { + this.problemsToReport = problemsToReport; + } + + getSummary() { + return this.problemsToReport; + } +} + +async function cleanAndGo(engine, server) { + await engine._tracker.clearChangedIDs(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + syncTestLogging(); + Service.recordManager.clearCache(); + await promiseStopServer(server); +} + +async function sync_engine(engine, errorRegex = undefined) { + // Clear out status so failures from previous syncs won't show in the record. + let { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" + ); + Status._engines = {}; + Status.partial = false; + // Neuter the scheduler as it interacts badly with some of the tests - the + // engine being synced usually isn't the registered engine, so we see + // scored incremented and not removed, which schedules unexpected syncs. + let oldObserve = Service.scheduler.observe; + Service.scheduler.observe = () => {}; + try { + Svc.Obs.notify("weave:service:sync:start"); + let caughtError; + try { + await engine.sync(); + } catch (e) { + caughtError = e; + } + if (caughtError) { + ok( + errorRegex.test(JSON.stringify(caughtError)), + "Engine sync error expected." + ); + Svc.Obs.notify("weave:service:sync:error", caughtError); + } else { + ok(!errorRegex, "No engine sync error expected."); + Svc.Obs.notify("weave:service:sync:finish"); + } + } finally { + Service.scheduler.observe = oldObserve; + } +} + +add_setup(async function () { + // Avoid addon manager complaining about not being initialized + await Service.engineManager.unregister("addons"); + await Service.engineManager.unregister("extension-storage"); + + do_get_profile(); // FOG requires a profile dir. + Services.fog.initializeFOG(); + + // We're not using `wait_for_ping` which means we aren't implicitly calling + // get_sync_test_telemetry() so often. So call it here for its side effects. + let telem = get_sync_test_telemetry(); + // Unlike the telemetry tests, we actually want the Glean APIs to be called. + // So pretend we're in production. + telem.isProductionSyncUser = () => true; +}); + +add_task(async function test_basic() { + enableValidationPrefs(); + + let helper = track_collections_helper(); + let upd = helper.with_updated_collection; + + let handlers = { + "/1.1/johndoe/info/collections": helper.handler, + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": upd( + "meta", + new ServerWBO("global").handler() + ), + }; + + let collections = [ + "clients", + "bookmarks", + "forms", + "history", + "passwords", + "prefs", + "tabs", + ]; + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = upd( + coll, + new ServerCollection({}, true).handler() + ); + } + + let server = httpd_setup(handlers); + await configureIdentity({ username: "johndoe" }, server); + + // Test that a basic batch can make it. + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + Glean.syncs.syncs.testGetValue().forEach(assert_success_sync); + }, + async () => { + await Service.sync(); + } + ); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); +}); + +add_task(async function test_processIncoming_error() { + let engine = Service.engineManager.get("bookmarks"); + await engine.initialize(); + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("bookmarks"); + try { + // Create a bogus record that when synced down will provoke a + // network error which in turn provokes an exception in _processIncoming. + const BOGUS_GUID = "zzzzzzzzzzzz"; + let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!"); + bogus_record.get = function get() { + throw new Error("Sync this!"); + }; + // Make it 10 minutes old so it will only be synced in the toFetch phase. + bogus_record.modified = Date.now() / 1000 - 60 * 10; + await engine.setLastSync(Date.now() / 1000 - 60); + engine.toFetch = new SerializableSet([BOGUS_GUID]); + + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + deepEqual(sync.failureReason, { name: "httperror", code: 500 }); + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + equal(e.name, "bookmarks-buffered"); + deepEqual(e.failureReason, { name: "httperror", code: 500 }); + }, + async () => { + await sync_engine(engine, /500 Internal Server Error/); + } + ); + } finally { + await store.wipe(); + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_uploading() { + // Clear out status, so failures from previous syncs won't show up in the + // telemetry ping. + let { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" + ); + Status._engines = {}; + Status.partial = false; + + let engine = Service.engineManager.get("bookmarks"); + await engine.initialize(); + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let bmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + + try { + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + equal(e.name, "bookmarks-buffered"); + greater(e.outgoing[0].sent, 0); + ok(!e.incoming); + }, + async () => { + await sync_engine(engine); + } + ); + + await PlacesUtils.bookmarks.update({ + guid: bmk.guid, + title: "New Title", + }); + + await store.wipe(); + await engine.resetClient(); + // We don't sync via the service, so don't re-hit info/collections, so + // lastModified remaning at zero breaks things subtly... + engine.lastModified = null; + + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + equal(e.name, "bookmarks-buffered"); + equal(e.outgoing.length, 1); + ok(!!e.incoming); + }, + async () => { + await sync_engine(engine); + } + ); + } finally { + // Clean up. + await store.wipe(); + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_upload_failed() { + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO("flying"); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + await configureIdentity({ username: "foo" }, server); + + let engine = new RotaryEngine(Service); + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + peppercorn: "Peppercorn Class", + }; + const FLYING_CHANGED = 12345; + const SCOTSMAN_CHANGED = 23456; + const PEPPERCORN_CHANGED = 34567; + await engine._tracker.addChangedID("flying", FLYING_CHANGED); + await engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED); + await engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + await engine.setLastSync(123); // needs to be non-zero so that tracker is queried + let changes = await engine._tracker.getChangedIDs(); + _( + `test_upload_failed: Rotary tracker contents at first sync: ${JSON.stringify( + changes + )}` + ); + engine.enabled = true; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + equal(e.incoming, null); + deepEqual(e.outgoing, [ + { + sent: 3, + failed: 2, + failedReasons: [ + { name: "scotsman", count: 1 }, + { name: "peppercorn", count: 1 }, + ], + }, + ]); + }, + async () => { + await sync_engine(engine); + } + ); + + await engine.setLastSync(123); + + changes = await engine._tracker.getChangedIDs(); + _( + `test_upload_failed: Rotary tracker contents at second sync: ${JSON.stringify( + changes + )}` + ); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + deepEqual(e.outgoing, [ + { + sent: 2, + failed: 2, + failedReasons: [ + { name: "scotsman", count: 1 }, + { name: "peppercorn", count: 1 }, + ], + }, + ]); + }, + async () => { + await sync_engine(engine); + } + ); + } finally { + await cleanAndGo(engine, server); + await engine.finalize(); + } +}); + +add_task(async function test_sync_partialUpload() { + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let engine = new RotaryEngine(Service); + await engine.setLastSync(123); + + // Create a bunch of records (and server side handlers) + for (let i = 0; i < 234; i++) { + let id = "record-no-" + i; + engine._store.items[id] = "Record No. " + i; + await engine._tracker.addChangedID(id, i); + // Let two items in the first upload batch fail. + if (i != 23 && i != 42) { + collection.insert(id); + } + } + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + let changes = await engine._tracker.getChangedIDs(); + _( + `test_sync_partialUpload: Rotary tracker contents at first sync: ${JSON.stringify( + changes + )}` + ); + engine.enabled = true; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + ok(!sync.failureReason); + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + equal(e.name, "rotary"); + ok(!e.incoming); + ok(!e.failureReason); + deepEqual(e.outgoing, [ + { + sent: 234, + failed: 2, + failedReasons: [ + { name: "record-no-23", count: 1 }, + { name: "record-no-42", count: 1 }, + ], + }, + ]); + }, + async () => { + await sync_engine(engine); + } + ); + + collection.post = function () { + throw new Error("Failure"); + }; + + engine._store.items["record-no-1000"] = "Record No. 1000"; + await engine._tracker.addChangedID("record-no-1000", 1000); + collection.insert("record-no-1000", 1000); + + await engine.setLastSync(123); + + changes = await engine._tracker.getChangedIDs(); + _( + `test_sync_partialUpload: Rotary tracker contents at second sync: ${JSON.stringify( + changes + )}` + ); + // It would be nice if we had a more descriptive error for this... + const uploadFailureError = { + name: "httperror", + code: 500, + }; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + deepEqual(sync.failureReason, uploadFailureError); + equal(sync.engines.length, 1); + + const e = sync.engines[0]; + equal(e.name, "rotary"); + deepEqual(e.incoming, { + failed: 1, + failedReasons: [ + { name: "No ciphertext: nothing to decrypt?", count: 1 }, + ], + }); + ok(!e.outgoing); + deepEqual(e.failureReason, uploadFailureError); + }, + async () => { + await sync_engine(engine, /500 Internal Server Error/); + } + ); + } finally { + await cleanAndGo(engine, server); + await engine.finalize(); + } +}); + +add_task(async function test_generic_engine_fail() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let e = new Error("generic failure message"); + engine._errToThrow = e; + + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + deepEqual( + sync.engines.find(err => err.name === "steam").failureReason, + { + name: "unexpectederror", + error: String(e), + } + ); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_engine_fail_weird_errors() { + enableValidationPrefs(); + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + try { + let msg = "Bad things happened!"; + engine._errToThrow = { message: msg }; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + deepEqual( + sync.engines.find(err => err.name === "steam").failureReason, + { + name: "unexpectederror", + error: msg, + } + ); + }, + async () => { + await Service.sync(); + } + ); + let e = { msg }; + engine._errToThrow = e; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + deepEqual( + sync.engines.find(err => err.name === "steam").failureReason, + { + name: "unexpectederror", + error: JSON.stringify(e), + } + ); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_overrideTelemetryName() { + enableValidationPrefs(["steam"]); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.overrideTelemetryName = "steam-but-better"; + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + const problemsToReport = [ + { name: "someProblem", count: 123 }, + { name: "anotherProblem", count: 456 }, + ]; + + try { + info("Sync with validation problems"); + engine.problemsToReport = problemsToReport; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + ok(!sync.engines.find(e => e.name === "steam")); + const eng = sync.engines.find(e => e.name === "steam-but-better"); + ok(eng); + delete eng.validation.took; // can't compare real times. + deepEqual( + eng.validation, + { + version: 1, + checked: 0, + problems: problemsToReport, + }, + "Should include validation report with overridden name" + ); + }, + async () => { + await Service.sync(); + } + ); + + info("Sync without validation problems"); + engine.problemsToReport = null; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + ok(!sync.engines.find(e => e.name === "steam")); + const eng = sync.engines.find(e => e.name === "steam-but-better"); + ok(eng); + ok( + !eng.validation, + "Should not include validation report when there are no problems" + ); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_engine_fail_ioerror() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + // create an IOError to re-throw as part of Sync. + try { + // (Note that fakeservices.js has replaced Utils.jsonMove etc, but for + // this test we need the real one so we get real exceptions from the + // filesystem.) + await Utils._real_jsonMove("file-does-not-exist", "anything", {}); + } catch (ex) { + engine._errToThrow = ex; + } + ok(engine._errToThrow, "expecting exception"); + + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_engine_fail_ioerror: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + const failureReason = sync.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + // ensure the profile dir in the exception message has been stripped. + ok( + !failureReason.error.includes(PathUtils.profileDir), + failureReason.error + ); + ok(failureReason.error.includes("[profileDir]"), failureReason.error); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_clean_urls() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + engine._errToThrow = new TypeError( + "http://www.google .com is not a valid URL." + ); + + try { + const changes = await engine._tracker.getChangedIDs(); + _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + const failureReason = sync.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + equal(failureReason.error, " is not a valid URL."); + }, + async () => { + await Service.sync(); + } + ); + // Handle other errors that include urls. + engine._errToThrow = + "Other error message that includes some:url/foo/bar/ in it."; + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + const failureReason = sync.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + equal( + failureReason.error, + "Other error message that includes in it." + ); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +// Arrange for a sync to hit a "real" OS error during a sync and make sure it's sanitized. +add_task(async function test_clean_real_os_error() { + enableValidationPrefs(); + + // Simulate a real error. + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let path = PathUtils.join(PathUtils.profileDir, "no", "such", "path.json"); + try { + await IOUtils.readJSON(path); + throw new Error("should fail to read the file"); + } catch (ex) { + engine._errToThrow = ex; + } + + try { + const changes = await engine._tracker.getChangedIDs(); + _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + const failureReason = sync.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + equal( + failureReason.error, + "OS error [File/Path not found] Could not open `[profileDir]/no/such/path.json': file does not exist" + ); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_initial_sync_engines() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + // These are the only ones who actually have things to sync at startup. + let telemetryEngineNames = ["clients", "prefs", "tabs", "bookmarks-buffered"]; + let server = await serverForEnginesWithKeys( + { foo: "password" }, + ["bookmarks", "prefs", "tabs"].map(name => Service.engineManager.get(name)) + ); + await SyncTestingInfrastructure(server); + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_initial_sync_engines: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.engines.find(e => e.name === "clients").outgoing[0].sent, 1); + equal(sync.engines.find(e => e.name === "tabs").outgoing[0].sent, 1); + + sync.engines + .filter(e => telemetryEngineNames.includes(e.name)) + .forEach(e => { + greaterOrEqual(e.took, 0); + ok(!!e.outgoing); + equal(e.outgoing.length, 1); + notEqual(e.outgoing[0].sent, undefined); + equal(e.outgoing[0].failed, undefined); + equal(e.outgoing[0].failedReasons, undefined); + }); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_nserror() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + engine._errToThrow = Components.Exception( + "NS_ERROR_UNKNOWN_HOST", + Cr.NS_ERROR_UNKNOWN_HOST + ); + try { + const changes = await engine._tracker.getChangedIDs(); + _(`test_nserror: Steam tracker contents: ${JSON.stringify(changes)}`); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + deepEqual(sync.status, { + service: SYNC_FAILED_PARTIAL, + sync: LOGIN_FAILED_NETWORK_ERROR, + }); + const eng = sync.engines.find(e => e.name === "steam"); + deepEqual(eng.failureReason, { + name: "httperror", + code: Cr.NS_ERROR_UNKNOWN_HOST, + }); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_sync_why() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let e = new Error("generic failure message"); + engine._errToThrow = e; + + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.hashedFxaUid.testGetValue(), "f".repeat(32)); + + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + + const sync = syncs[0]; + equal(sync.why, "user"); + }, + async () => { + await Service.sync({ why: "user" }); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_discarding() { + enableValidationPrefs(); + + let helper = track_collections_helper(); + let upd = helper.with_updated_collection; + let telem = get_sync_test_telemetry(); + telem.maxPayloadCount = 2; + telem.submissionInterval = Infinity; + + let server; + try { + let handlers = { + "/1.1/johndoe/info/collections": helper.handler, + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": upd( + "meta", + new ServerWBO("global").handler() + ), + }; + + let collections = [ + "clients", + "bookmarks", + "forms", + "history", + "passwords", + "prefs", + "tabs", + ]; + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = upd( + coll, + new ServerCollection({}, true).handler() + ); + } + + server = httpd_setup(handlers); + await configureIdentity({ username: "johndoe" }, server); + GleanPings.sync.testBeforeNextSubmit(() => { + ok(false, "Submitted telemetry ping when we should not have."); + }); + + for (let i = 0; i < 5; ++i) { + await Service.sync(); + } + telem.submissionInterval = -1; + + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.discarded.testGetValue(), 4); + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 2); + syncs.forEach(assert_success_sync); + }, + async () => { + await Service.sync(); // Sixth time's the charm. + } + ); + } finally { + telem.maxPayloadCount = 500; + telem.submissionInterval = -1; + if (server) { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_no_foreign_engines_in_error_ping() { + enableValidationPrefs(); + + await Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let server = await serverForFoo(engine); + engine._errToThrow = new Error("Oh no!"); + await SyncTestingInfrastructure(server); + try { + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + const sync = syncs[0]; + equal(sync.status.service, SYNC_FAILED_PARTIAL); + ok(sync.engines.every(e => e.name !== "bogus")); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_no_foreign_engines_in_success_ping() { + enableValidationPrefs(); + + await Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + try { + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + const syncs = Glean.syncs.syncs.testGetValue(); + equal(syncs.length, 1); + const sync = syncs[0]; + ok(sync.engines.every(e => e.name !== "bogus")); + }, + async () => { + await Service.sync(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_no_node_type() { + let server = sync_httpd_setup({}); + await configureIdentity(null, server); + + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + Assert.strictEqual(Glean.syncs.syncNodeType.testGetValue(), null); + }, + async () => { + await Service.sync(); + } + ); + await promiseStopServer(server); +}); + +add_task(async function test_node_type() { + Service.identity.logout(); + let server = sync_httpd_setup({}); + await configureIdentity({ node_type: "the-node-type" }, server); + + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "schedule"); + equal(Glean.syncs.syncNodeType.testGetValue(), "the-node-type"); + }, + async () => { + await Service.sync(); + } + ); + await promiseStopServer(server); +}); + +add_task(async function test_node_type_change() { + Service.identity.logout(); + let server = sync_httpd_setup({}); + await configureIdentity({ node_type: "first-node-type" }, server); + // Default to submitting each hour - we should still submit on node change. + let telem = get_sync_test_telemetry(); + telem.submissionInterval = 60 * 60 * 1000; + // reset the node type from previous test or our first sync will submit. + telem.lastSyncNodeType = null; + // do 2 syncs with the same node type. + await Service.sync(); + await Service.sync(); + // then another with a different node type. + Service.identity.logout(); + await configureIdentity({ node_type: "second-node-type" }, server); + await GleanPings.sync.testSubmission( + () => { + equal( + Glean.syncs.syncs.testGetValue().length, + 2, + "2 syncs in first ping" + ); + equal(Glean.syncs.syncNodeType.testGetValue(), "first-node-type"); + }, + async () => { + await Service.sync(); + } + ); + await GleanPings.sync.testSubmission( + () => { + equal( + Glean.syncs.syncs.testGetValue().length, + 1, + "1 sync in second ping" + ); + equal(Glean.syncs.syncNodeType.testGetValue(), "second-node-type"); + }, + async () => { + telem.finish(); + } + ); + await promiseStopServer(server); +}); + +add_task(async function test_uid_change() { + enableValidationPrefs(); + + await Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + let telem = get_sync_test_telemetry(); + telem.maxPayloadCount = 500; + telem.submissionInterval = Infinity; + + // a sync with the "old" uid. + await Service.sync(); + + fxAccounts.telemetry._setHashedUID("deadbeef"); + + try { + await GleanPings.sync.testSubmission( + reason => { + equal(reason, "idchange"); + equal( + Glean.syncs.syncs.testGetValue().length, + 1, + "first sync in its own ping" + ); + }, + async () => { + await Service.sync(); + } + ); + await GleanPings.sync.testSubmission( + () => { + equal( + Glean.syncs.syncs.testGetValue().length, + 1, + "second sync in its own ping" + ); + }, + async () => { + telem.finish(); + } + ); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_deletion_request_ping() { + async function assertRecordedSyncDeviceID(expected) { + // `onAccountInitOrChange` sets the id asynchronously, so wait a tick. + await Promise.resolve(); + equal(Glean.deletionRequest.syncDeviceId.testGetValue(), expected); + } + Services.fog.testResetFOG(); + + const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff"; + const MOCK_DEVICE_ID1 = "ffeeddccbbaa99887766554433221100"; + const MOCK_DEVICE_ID2 = "aabbccddeeff99887766554433221100"; + + // Calculated by hand using SHA256(DEVICE_ID + HASHED_UID)[:32] + const SANITIZED_DEVICE_ID1 = "dd7c845006df9baa1c6d756926519c8c"; + const SANITIZED_DEVICE_ID2 = "0d06919a736fc029007e1786a091882c"; + + let currentDeviceID = null; + sinon.stub(fxAccounts.device, "getLocalId").callsFake(() => { + return Promise.resolve(currentDeviceID); + }); + let telem = get_sync_test_telemetry(); + sinon.stub(telem, "isProductionSyncUser").callsFake(() => true); + fxAccounts.telemetry._setHashedUID(false); + + try { + // The scalar should start out undefined, since no user is actually logged in. + await assertRecordedSyncDeviceID(null); + + // If we start up without knowing the hashed UID, it should stay undefined. + telem.observe(null, "weave:service:ready"); + await assertRecordedSyncDeviceID(null); + + // But now let's say we've discovered the hashed UID from the server. + fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID); + currentDeviceID = MOCK_DEVICE_ID1; + + // Now when we load up, we'll record the sync device id. + telem.observe(null, "weave:service:ready"); + await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID1); + + // When the device-id changes we'll update it. + currentDeviceID = MOCK_DEVICE_ID2; + telem.observe(null, "fxaccounts:new_device_id"); + await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID2); + + // When the user signs out we'll clear it. + telem.observe(null, "fxaccounts:onlogout"); + await assertRecordedSyncDeviceID(""); + } finally { + fxAccounts.telemetry._setHashedUID(false); + telem.isProductionSyncUser.restore(); + fxAccounts.device.getLocalId.restore(); + } +}); + +// TODO: Is the topic `"weave:telemetry:migration"` presently hooked up? +add_task(async function test_migration() { + let telem = get_sync_test_telemetry(); + const migrationInfo = { + entries: 42, + entries_successful: 42, + extensions: 84, + extensions_successful: 83, + openFailure: false, + }; + telem._addMigrationRecord("webext-storage", migrationInfo); + await GleanPings.sync.testSubmission( + () => { + const migrations = Glean.syncs.migrations.testGetValue(); + equal(migrations.length, 1); + deepEqual(migrations[0], { + migration_type: "webext-storage", + entries: 42, + entriesSuccessful: 42, + extensions: 84, + extensionsSuccessful: 83, + openFailure: false, + }); + }, + () => { + telem.finish(); + } + ); +}); diff --git a/src/zen/tests/mochitests/sync/test_history_engine.js b/src/zen/tests/mochitests/sync/test_history_engine.js new file mode 100644 index 000000000..15c10c987 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_history_engine.js @@ -0,0 +1,429 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { HistoryEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/history.sys.mjs" +); + +// Use only for rawAddVisit. +XPCOMUtils.defineLazyServiceGetter( + this, + "asyncHistory", + "@mozilla.org/browser/history;1", + Ci.mozIAsyncHistory +); +async function rawAddVisit(id, uri, visitPRTime, transitionType) { + return new Promise(resolve => { + let results = []; + let handler = { + handleResult(result) { + results.push(result); + }, + handleError(resultCode) { + do_throw(`updatePlaces gave error ${resultCode}!`); + }, + handleCompletion(count) { + resolve({ results, count }); + }, + }; + asyncHistory.updatePlaces( + [ + { + guid: id, + uri: typeof uri == "string" ? CommonUtils.makeURI(uri) : uri, + visits: [{ visitDate: visitPRTime, transitionType }], + }, + ], + handler + ); + }); +} + +add_task(async function test_history_download_limit() { + let engine = new HistoryEngine(Service); + await engine.initialize(); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let lastSync = new_timestamp(); + + let collection = server.user("foo").collection("history"); + for (let i = 0; i < 15; i++) { + let id = "place" + i.toString(10).padStart(7, "0"); + let wbo = new ServerWBO( + id, + encryptPayload({ + id, + histUri: "http://example.com/" + i, + title: "Page " + i, + visits: [ + { + date: Date.now() * 1000, + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + date: Date.now() * 1000, + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + ], + }), + lastSync + 1 + i + ); + wbo.sortindex = 15 - i; + collection.insertWBO(wbo); + } + + // We have 15 records on the server since the last sync, but our download + // limit is 5 records at a time. We should eventually fetch all 15. + await engine.setLastSync(lastSync); + engine.downloadBatchSize = 4; + engine.downloadLimit = 5; + + // Don't actually fetch any backlogged records, so that we can inspect + // the backlog between syncs. + engine.guidFetchBatchSize = 0; + + let ping = await sync_engine_and_validate_telem(engine, false); + deepEqual(ping.engines[0].incoming, { applied: 5 }); + + let backlogAfterFirstSync = Array.from(engine.toFetch).sort(); + deepEqual(backlogAfterFirstSync, [ + "place0000000", + "place0000001", + "place0000002", + "place0000003", + "place0000004", + "place0000005", + "place0000006", + "place0000007", + "place0000008", + "place0000009", + ]); + + // We should have fast-forwarded the last sync time. + equal(await engine.getLastSync(), lastSync + 15); + + engine.lastModified = collection.modified; + ping = await sync_engine_and_validate_telem(engine, false); + ok(!ping.engines[0].incoming); + + // After the second sync, our backlog still contains the same GUIDs: we + // weren't able to make progress on fetching them, since our + // `guidFetchBatchSize` is 0. + let backlogAfterSecondSync = Array.from(engine.toFetch).sort(); + deepEqual(backlogAfterFirstSync, backlogAfterSecondSync); + + // Now add a newer record to the server. + let newWBO = new ServerWBO( + "placeAAAAAAA", + encryptPayload({ + id: "placeAAAAAAA", + histUri: "http://example.com/a", + title: "New Page A", + visits: [ + { + date: Date.now() * 1000, + type: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ], + }), + lastSync + 20 + ); + newWBO.sortindex = -1; + collection.insertWBO(newWBO); + + engine.lastModified = collection.modified; + ping = await sync_engine_and_validate_telem(engine, false); + deepEqual(ping.engines[0].incoming, { applied: 1 }); + + // Our backlog should remain the same. + let backlogAfterThirdSync = Array.from(engine.toFetch).sort(); + deepEqual(backlogAfterSecondSync, backlogAfterThirdSync); + + equal(await engine.getLastSync(), lastSync + 20); + + // Bump the fetch batch size to let the backlog make progress. We should + // make 3 requests to fetch 5 backlogged GUIDs. + engine.guidFetchBatchSize = 2; + + engine.lastModified = collection.modified; + ping = await sync_engine_and_validate_telem(engine, false); + deepEqual(ping.engines[0].incoming, { applied: 5 }); + + deepEqual(Array.from(engine.toFetch).sort(), [ + "place0000005", + "place0000006", + "place0000007", + "place0000008", + "place0000009", + ]); + + // Sync again to clear out the backlog. + engine.lastModified = collection.modified; + ping = await sync_engine_and_validate_telem(engine, false); + deepEqual(ping.engines[0].incoming, { applied: 5 }); + + deepEqual(Array.from(engine.toFetch), []); + + await engine.wipeClient(); + await engine.finalize(); +}); + +add_task(async function test_history_visit_roundtrip() { + let engine = new HistoryEngine(Service); + await engine.initialize(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + engine._tracker.start(); + + let id = "aaaaaaaaaaaa"; + let oneHourMS = 60 * 60 * 1000; + // Insert a visit with a non-round microsecond timestamp (e.g. it's not evenly + // divisible by 1000). This will typically be the case for visits that occur + // during normal navigation. + let time = (Date.now() - oneHourMS) * 1000 + 555; + // We use the low level history api since it lets us provide microseconds + let { count } = await rawAddVisit( + id, + "https://www.example.com", + time, + PlacesUtils.history.TRANSITIONS.TYPED + ); + equal(count, 1); + // Check that it was inserted and that we didn't round on the insert. + let visits = await PlacesSyncUtils.history.fetchVisitsForURL( + "https://www.example.com" + ); + equal(visits.length, 1); + equal(visits[0].date, time); + + let collection = server.user("foo").collection("history"); + + // Sync the visit up to the server. + await sync_engine_and_validate_telem(engine, false); + + collection.updateRecord( + id, + cleartext => { + // Double-check that we didn't round the visit's timestamp to the nearest + // millisecond when uploading. + equal(cleartext.visits[0].date, time); + // Add a remote visit so that we get past the deepEquals check in reconcile + // (otherwise the history engine will skip applying this record). The + // contents of this visit don't matter, beyond the fact that it needs to + // exist. + cleartext.visits.push({ + date: (Date.now() - oneHourMS / 2) * 1000, + type: PlacesUtils.history.TRANSITIONS.LINK, + }); + }, + new_timestamp() + 10 + ); + + // Force a remote sync. + await engine.setLastSync(new_timestamp() - 30); + await sync_engine_and_validate_telem(engine, false); + + // Make sure that we didn't duplicate the visit when inserting. (Prior to bug + // 1423395, we would insert a duplicate visit, where the timestamp was + // effectively `Math.round(microsecondTimestamp / 1000) * 1000`.) + visits = await PlacesSyncUtils.history.fetchVisitsForURL( + "https://www.example.com" + ); + equal(visits.length, 2); + + await engine.wipeClient(); + await engine.finalize(); +}); + +add_task(async function test_history_visit_dedupe_old() { + let engine = new HistoryEngine(Service); + await engine.initialize(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let initialVisits = Array.from({ length: 25 }, (_, index) => ({ + transition: PlacesUtils.history.TRANSITION_LINK, + date: new Date(Date.UTC(2017, 10, 1 + index)), + })); + initialVisits.push({ + transition: PlacesUtils.history.TRANSITION_LINK, + date: new Date(), + }); + await PlacesUtils.history.insert({ + url: "https://www.example.com", + visits: initialVisits, + }); + + let recentVisits = await PlacesSyncUtils.history.fetchVisitsForURL( + "https://www.example.com" + ); + equal(recentVisits.length, 20); + let { visits: allVisits, guid } = await PlacesUtils.history.fetch( + "https://www.example.com", + { + includeVisits: true, + } + ); + equal(allVisits.length, 26); + + let collection = server.user("foo").collection("history"); + + await sync_engine_and_validate_telem(engine, false); + + collection.updateRecord( + guid, + data => { + data.visits.push( + // Add a couple remote visit equivalent to some old visits we have already + { + date: Date.UTC(2017, 10, 1) * 1000, // Nov 1, 2017 + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + { + date: Date.UTC(2017, 10, 2) * 1000, // Nov 2, 2017 + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + // Add a couple new visits to make sure we are still applying them. + { + date: Date.UTC(2017, 11, 4) * 1000, // Dec 4, 2017 + type: PlacesUtils.history.TRANSITIONS.LINK, + }, + { + date: Date.UTC(2017, 11, 5) * 1000, // Dec 5, 2017 + type: PlacesUtils.history.TRANSITIONS.LINK, + } + ); + }, + new_timestamp() + 10 + ); + + await engine.setLastSync(new_timestamp() - 30); + await sync_engine_and_validate_telem(engine, false); + + allVisits = ( + await PlacesUtils.history.fetch("https://www.example.com", { + includeVisits: true, + }) + ).visits; + + equal(allVisits.length, 28); + ok( + allVisits.find(x => x.date.getTime() === Date.UTC(2017, 11, 4)), + "Should contain the Dec. 4th visit" + ); + ok( + allVisits.find(x => x.date.getTime() === Date.UTC(2017, 11, 5)), + "Should contain the Dec. 5th visit" + ); + + await engine.wipeClient(); + await engine.finalize(); +}); + +add_task(async function test_history_unknown_fields() { + let engine = new HistoryEngine(Service); + await engine.initialize(); + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + engine._tracker.start(); + + let id = "aaaaaaaaaaaa"; + let oneHourMS = 60 * 60 * 1000; + // Insert a visit with a non-round microsecond timestamp (e.g. it's not evenly + // divisible by 1000). This will typically be the case for visits that occur + // during normal navigation. + let time = (Date.now() - oneHourMS) * 1000 + 555; + // We use the low level history api since it lets us provide microseconds + let { count } = await rawAddVisit( + id, + "https://www.example.com", + time, + PlacesUtils.history.TRANSITIONS.TYPED + ); + equal(count, 1); + + let collection = server.user("foo").collection("history"); + + // Sync the visit up to the server. + await sync_engine_and_validate_telem(engine, false); + + collection.updateRecord( + id, + cleartext => { + equal(cleartext.visits[0].date, time); + + // Add unknown fields to an instance of a visit + cleartext.visits.push({ + date: (Date.now() - oneHourMS / 2) * 1000, + type: PlacesUtils.history.TRANSITIONS.LINK, + unknownVisitField: "an unknown field could show up in a visit!", + }); + cleartext.title = "A page title"; + // Add unknown fields to the payload for this URL + cleartext.unknownStrField = "an unknown str field"; + cleartext.unknownObjField = { newField: "a field within an object" }; + }, + new_timestamp() + 10 + ); + + // Force a remote sync. + await engine.setLastSync(new_timestamp() - 30); + await sync_engine_and_validate_telem(engine, false); + + // Add a new visit to ensure we're actually putting things back on the server + let newTime = (Date.now() - oneHourMS) * 1000 + 555; + await rawAddVisit( + id, + "https://www.example.com", + newTime, + PlacesUtils.history.TRANSITIONS.LINK + ); + + // Sync again + await engine.setLastSync(new_timestamp() - 30); + await sync_engine_and_validate_telem(engine, false); + + let placeInfo = await PlacesSyncUtils.history.fetchURLInfoForGuid(id); + + // Found the place we're looking for + Assert.equal(placeInfo.title, "A page title"); + Assert.equal(placeInfo.url, "https://www.example.com/"); + + // It correctly returns any unknownFields that might've been + // stored in the moz_places_extra table + deepEqual(JSON.parse(placeInfo.unknownFields), { + unknownStrField: "an unknown str field", + unknownObjField: { newField: "a field within an object" }, + }); + + // Getting visits via SyncUtils also will return unknownFields + // via the moz_historyvisits_extra table + let visits = await PlacesSyncUtils.history.fetchVisitsForURL( + "https://www.example.com" + ); + equal(visits.length, 3); + + // fetchVisitsForURL is a sync method that gets called during upload + // so unknown field should already be at the top-level + deepEqual( + visits[0].unknownVisitField, + "an unknown field could show up in a visit!" + ); + + // Remote history record should have the fields back at the top level + let remotePlace = collection.payloads().find(rec => rec.id === id); + deepEqual(remotePlace.unknownStrField, "an unknown str field"); + deepEqual(remotePlace.unknownObjField, { + newField: "a field within an object", + }); + + await engine.wipeClient(); + await engine.finalize(); +}); diff --git a/src/zen/tests/mochitests/sync/test_history_store.js b/src/zen/tests/mochitests/sync/test_history_store.js new file mode 100644 index 000000000..a6d9a181e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_history_store.js @@ -0,0 +1,560 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { HistoryEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/history.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { SyncedRecordsTelemetry } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" +); + +const TIMESTAMP1 = (Date.now() - 103406528) * 1000; +const TIMESTAMP2 = (Date.now() - 6592903) * 1000; +const TIMESTAMP3 = (Date.now() - 123894) * 1000; + +function isDateApproximately(actual, expected, skewMillis = 1000) { + let lowerBound = expected - skewMillis; + let upperBound = expected + skewMillis; + return actual >= lowerBound && actual <= upperBound; +} + +let engine, store, fxuri, fxguid, tburi, tbguid; + +async function applyEnsureNoFailures(records) { + let countTelemetry = new SyncedRecordsTelemetry(); + Assert.equal( + (await store.applyIncomingBatch(records, countTelemetry)).length, + 0 + ); +} + +add_task(async function setup() { + engine = new HistoryEngine(Service); + await engine.initialize(); + store = engine._store; +}); + +add_task(async function test_store() { + _("Verify that we've got an empty store to work with."); + do_check_empty(await store.getAllIDs()); + + _("Let's create an entry in the database."); + fxuri = CommonUtils.makeURI("http://getfirefox.com/"); + + await PlacesTestUtils.addVisits({ + uri: fxuri, + title: "Get Firefox!", + visitDate: TIMESTAMP1, + }); + _("Verify that the entry exists."); + let ids = Object.keys(await store.getAllIDs()); + Assert.equal(ids.length, 1); + fxguid = ids[0]; + Assert.ok(await store.itemExists(fxguid)); + + _("If we query a non-existent record, it's marked as deleted."); + let record = await store.createRecord("non-existent"); + Assert.ok(record.deleted); + + _("Verify createRecord() returns a complete record."); + record = await store.createRecord(fxguid); + Assert.equal(record.histUri, fxuri.spec); + Assert.equal(record.title, "Get Firefox!"); + Assert.equal(record.visits.length, 1); + Assert.equal(record.visits[0].date, TIMESTAMP1); + Assert.equal(record.visits[0].type, Ci.nsINavHistoryService.TRANSITION_LINK); + + _("Let's modify the record and have the store update the database."); + let secondvisit = { + date: TIMESTAMP2, + type: Ci.nsINavHistoryService.TRANSITION_TYPED, + }; + let onVisitObserved = PlacesTestUtils.waitForNotification(["page-visited"]); + let updatedRec = await store.createRecord(fxguid); + updatedRec.cleartext.title = "Hol Dir Firefox!"; + updatedRec.cleartext.visits.push(secondvisit); + await applyEnsureNoFailures([updatedRec]); + await onVisitObserved; + let queryres = await PlacesUtils.history.fetch(fxuri.spec, { + includeVisits: true, + }); + Assert.equal(queryres.title, "Hol Dir Firefox!"); + Assert.deepEqual(queryres.visits, [ + { + date: new Date(TIMESTAMP2 / 1000), + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + date: new Date(TIMESTAMP1 / 1000), + transition: Ci.nsINavHistoryService.TRANSITION_LINK, + }, + ]); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_store_create() { + _("Create a brand new record through the store."); + tbguid = Utils.makeGUID(); + tburi = CommonUtils.makeURI("http://getthunderbird.com"); + let onVisitObserved = PlacesTestUtils.waitForNotification(["page-visited"]); + let record = await store.createRecord(tbguid); + record.cleartext = { + id: tbguid, + histUri: tburi.spec, + title: "The bird is the word!", + visits: [ + { date: TIMESTAMP3, type: Ci.nsINavHistoryService.TRANSITION_TYPED }, + ], + }; + await applyEnsureNoFailures([record]); + await onVisitObserved; + Assert.ok(await store.itemExists(tbguid)); + do_check_attribute_count(await store.getAllIDs(), 1); + let queryres = await PlacesUtils.history.fetch(tburi.spec, { + includeVisits: true, + }); + Assert.equal(queryres.title, "The bird is the word!"); + Assert.deepEqual(queryres.visits, [ + { + date: new Date(TIMESTAMP3 / 1000), + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_null_title() { + _( + "Make sure we handle a null title gracefully (it can happen in some cases, e.g. for resource:// URLs)" + ); + let resguid = Utils.makeGUID(); + let resuri = CommonUtils.makeURI("unknown://title"); + let record = await store.createRecord(resguid); + record.cleartext = { + id: resguid, + histUri: resuri.spec, + title: null, + visits: [ + { date: TIMESTAMP3, type: Ci.nsINavHistoryService.TRANSITION_TYPED }, + ], + }; + await applyEnsureNoFailures([record]); + do_check_attribute_count(await store.getAllIDs(), 1); + + let queryres = await PlacesUtils.history.fetch(resuri.spec, { + includeVisits: true, + }); + Assert.equal(queryres.title, ""); + Assert.deepEqual(queryres.visits, [ + { + date: new Date(TIMESTAMP3 / 1000), + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_invalid_records() { + _("Make sure we handle invalid URLs in places databases gracefully."); + await PlacesUtils.withConnectionWrapper( + "test_invalid_record", + async function (db) { + await db.execute( + "INSERT INTO moz_places " + + "(url, url_hash, title, rev_host, visit_count, last_visit_date) " + + "VALUES ('invalid-uri', hash('invalid-uri'), 'Invalid URI', '.', 1, " + + TIMESTAMP3 + + ")" + ); + // Add the corresponding visit to retain database coherence. + await db.execute( + "INSERT INTO moz_historyvisits " + + "(place_id, visit_date, visit_type, session) " + + "VALUES ((SELECT id FROM moz_places WHERE url_hash = hash('invalid-uri') AND url = 'invalid-uri'), " + + TIMESTAMP3 + + ", " + + Ci.nsINavHistoryService.TRANSITION_TYPED + + ", 1)" + ); + } + ); + do_check_attribute_count(await store.getAllIDs(), 1); + + _("Make sure we report records with invalid URIs."); + let invalid_uri_guid = Utils.makeGUID(); + let countTelemetry = new SyncedRecordsTelemetry(); + let failed = await store.applyIncomingBatch( + [ + { + id: invalid_uri_guid, + histUri: ":::::::::::::::", + title: "Doesn't have a valid URI", + visits: [ + { date: TIMESTAMP3, type: Ci.nsINavHistoryService.TRANSITION_EMBED }, + ], + }, + ], + countTelemetry + ); + Assert.equal(failed.length, 1); + Assert.equal(failed[0], invalid_uri_guid); + Assert.equal( + countTelemetry.incomingCounts.failedReasons[0].name, + " is not a valid URL." + ); + Assert.equal(countTelemetry.incomingCounts.failedReasons[0].count, 1); + + _("Make sure we handle records with invalid GUIDs gracefully (ignore)."); + await applyEnsureNoFailures([ + { + id: "invalid", + histUri: "http://invalid.guid/", + title: "Doesn't have a valid GUID", + visits: [ + { date: TIMESTAMP3, type: Ci.nsINavHistoryService.TRANSITION_EMBED }, + ], + }, + ]); + + _( + "Make sure we handle records with invalid visit codes or visit dates, gracefully ignoring those visits." + ); + let no_date_visit_guid = Utils.makeGUID(); + let no_type_visit_guid = Utils.makeGUID(); + let invalid_type_visit_guid = Utils.makeGUID(); + let non_integer_visit_guid = Utils.makeGUID(); + countTelemetry = new SyncedRecordsTelemetry(); + failed = await store.applyIncomingBatch( + [ + { + id: no_date_visit_guid, + histUri: "http://no.date.visit/", + title: "Visit has no date", + visits: [{ type: Ci.nsINavHistoryService.TRANSITION_EMBED }], + }, + { + id: no_type_visit_guid, + histUri: "http://no.type.visit/", + title: "Visit has no type", + visits: [{ date: TIMESTAMP3 }], + }, + { + id: invalid_type_visit_guid, + histUri: "http://invalid.type.visit/", + title: "Visit has invalid type", + visits: [ + { + date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_LINK - 1, + }, + ], + }, + { + id: non_integer_visit_guid, + histUri: "http://non.integer.visit/", + title: "Visit has non-integer date", + visits: [ + { date: 1234.567, type: Ci.nsINavHistoryService.TRANSITION_EMBED }, + ], + }, + ], + countTelemetry + ); + Assert.equal(failed.length, 0); + + // Make sure we can apply tombstones (both valid and invalid) + countTelemetry = new SyncedRecordsTelemetry(); + failed = await store.applyIncomingBatch( + [ + { id: no_date_visit_guid, deleted: true }, + { id: "not-a-valid-guid", deleted: true }, + ], + countTelemetry + ); + Assert.deepEqual(failed, ["not-a-valid-guid"]); + Assert.equal( + countTelemetry.incomingCounts.failedReasons[0].name, + " is not a valid URL." + ); + + _("Make sure we handle records with javascript: URLs gracefully."); + await applyEnsureNoFailures( + [ + { + id: Utils.makeGUID(), + histUri: "javascript:''", + title: "javascript:''", + visits: [ + { date: TIMESTAMP3, type: Ci.nsINavHistoryService.TRANSITION_EMBED }, + ], + }, + ], + countTelemetry + ); + + _("Make sure we handle records without any visits gracefully."); + await applyEnsureNoFailures([ + { + id: Utils.makeGUID(), + histUri: "http://getfirebug.com", + title: "Get Firebug!", + visits: [], + }, + ]); +}); + +add_task(async function test_unknowingly_invalid_records() { + _("Make sure we handle rejection of records by places gracefully."); + let oldCAU = store._canAddURI; + store._canAddURI = () => true; + try { + _("Make sure that when places rejects this record we record it as failed"); + let guid = Utils.makeGUID(); + let countTelemetry = new SyncedRecordsTelemetry(); + let invalidRecord = await store.createRecord(guid); + invalidRecord.cleartext = { + id: guid, + histUri: "javascript:''", + title: "javascript:''", + visits: [ + { + date: TIMESTAMP3, + type: Ci.nsINavHistoryService.TRANSITION_EMBED, + }, + ], + }; + let result = await store.applyIncomingBatch( + [invalidRecord], + countTelemetry + ); + deepEqual(result, [guid]); + } finally { + store._canAddURI = oldCAU; + } +}); + +add_task(async function test_clamp_visit_dates() { + let futureVisitTime = Date.now() + 5 * 60 * 1000; + let recentVisitTime = Date.now() - 5 * 60 * 1000; + + let recordA = await store.createRecord("visitAAAAAAA"); + recordA.cleartext = { + id: "visitAAAAAAA", + histUri: "http://example.com/a", + title: "A", + visits: [ + { + date: "invalidDate", + type: Ci.nsINavHistoryService.TRANSITION_LINK, + }, + ], + }; + let recordB = await store.createRecord("visitBBBBBBB"); + recordB.cleartext = { + id: "visitBBBBBBB", + histUri: "http://example.com/b", + title: "B", + visits: [ + { + date: 100, + type: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + date: 250, + type: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + date: recentVisitTime * 1000, + type: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ], + }; + let recordC = await store.createRecord("visitCCCCCCC"); + recordC.cleartext = { + id: "visitCCCCCCC", + histUri: "http://example.com/c", + title: "D", + visits: [ + { + date: futureVisitTime * 1000, + type: Ci.nsINavHistoryService.TRANSITION_BOOKMARK, + }, + ], + }; + let recordD = await store.createRecord("visitDDDDDDD"); + recordD.cleartext = { + id: "visitDDDDDDD", + histUri: "http://example.com/d", + title: "D", + visits: [ + { + date: recentVisitTime * 1000, + type: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + ], + }; + await applyEnsureNoFailures([recordA, recordB, recordC, recordD]); + + let visitsForA = await PlacesSyncUtils.history.fetchVisitsForURL( + "http://example.com/a" + ); + deepEqual(visitsForA, [], "Should ignore visits with invalid dates"); + + let visitsForB = await PlacesSyncUtils.history.fetchVisitsForURL( + "http://example.com/b" + ); + deepEqual( + visitsForB, + [ + { + date: recentVisitTime * 1000, + type: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + // We should clamp visit dates older than original Mosaic release. + date: PlacesSyncUtils.bookmarks.EARLIEST_BOOKMARK_TIMESTAMP * 1000, + type: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ], + "Should record clamped visit and valid visit for B" + ); + + let visitsForC = await PlacesSyncUtils.history.fetchVisitsForURL( + "http://example.com/c" + ); + equal(visitsForC.length, 1, "Should record clamped future visit for C"); + let visitDateForC = PlacesUtils.toDate(visitsForC[0].date); + ok( + isDateApproximately(visitDateForC, Date.now()), + "Should clamp future visit date for C to now" + ); + + let visitsForD = await PlacesSyncUtils.history.fetchVisitsForURL( + "http://example.com/d" + ); + deepEqual( + visitsForD, + [ + { + date: recentVisitTime * 1000, + type: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD, + }, + ], + "Should not clamp valid visit dates" + ); +}); + +add_task(async function test_remove() { + _("Remove an existent record and a non-existent from the store."); + await applyEnsureNoFailures([ + { id: fxguid, deleted: true }, + { id: Utils.makeGUID(), deleted: true }, + ]); + Assert.equal(false, await store.itemExists(fxguid)); + let queryres = await PlacesUtils.history.fetch(fxuri.spec, { + includeVisits: true, + }); + Assert.equal(null, queryres); + + _("Make sure wipe works."); + await store.wipe(); + do_check_empty(await store.getAllIDs()); + queryres = await PlacesUtils.history.fetch(fxuri.spec, { + includeVisits: true, + }); + Assert.equal(null, queryres); + queryres = await PlacesUtils.history.fetch(tburi.spec, { + includeVisits: true, + }); + Assert.equal(null, queryres); +}); + +add_task(async function test_chunking() { + let mvpi = store.MAX_VISITS_PER_INSERT; + store.MAX_VISITS_PER_INSERT = 3; + let checkChunks = function (input, expected) { + let chunks = Array.from(store._generateChunks(input)); + deepEqual(chunks, expected); + }; + try { + checkChunks([{ visits: ["x"] }], [[{ visits: ["x"] }]]); + + // 3 should still be one chunk. + checkChunks([{ visits: ["x", "x", "x"] }], [[{ visits: ["x", "x", "x"] }]]); + + // 4 should still be one chunk as we don't split individual records. + checkChunks( + [{ visits: ["x", "x", "x", "x"] }], + [[{ visits: ["x", "x", "x", "x"] }]] + ); + + // 4 in the first and 1 in the second should be 2 chunks. + checkChunks( + [{ visits: ["x", "x", "x", "x"] }, { visits: ["x"] }], + // expected + [[{ visits: ["x", "x", "x", "x"] }], [{ visits: ["x"] }]] + ); + + // we put multiple records into chunks + checkChunks( + [ + { visits: ["x", "x"] }, + { visits: ["x"] }, + { visits: ["x"] }, + { visits: ["x", "x"] }, + { visits: ["x", "x", "x", "x"] }, + ], + // expected + [ + [{ visits: ["x", "x"] }, { visits: ["x"] }], + [{ visits: ["x"] }, { visits: ["x", "x"] }], + [{ visits: ["x", "x", "x", "x"] }], + ] + ); + } finally { + store.MAX_VISITS_PER_INSERT = mvpi; + } +}); + +add_task(async function test_getAllIDs_filters_file_uris() { + let uri = CommonUtils.makeURI("file:///Users/eoger/tps/config.json"); + let visitAddedPromise = promiseVisit("added", uri); + await PlacesTestUtils.addVisits({ + uri, + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_LINK, + }); + await visitAddedPromise; + + do_check_attribute_count(await store.getAllIDs(), 0); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_applyIncomingBatch_filters_file_uris() { + const guid = Utils.makeGUID(); + let uri = CommonUtils.makeURI("file:///Users/eoger/tps/config.json"); + await applyEnsureNoFailures([ + { + id: guid, + histUri: uri.spec, + title: "TPS CONFIG", + visits: [ + { date: TIMESTAMP3, type: Ci.nsINavHistoryService.TRANSITION_TYPED }, + ], + }, + ]); + Assert.equal(false, await store.itemExists(guid)); + let queryres = await PlacesUtils.history.fetch(uri.spec, { + includeVisits: true, + }); + Assert.equal(null, queryres); +}); + +add_task(async function cleanup() { + _("Clean up."); + await PlacesUtils.history.clear(); +}); diff --git a/src/zen/tests/mochitests/sync/test_history_tracker.js b/src/zen/tests/mochitests/sync/test_history_tracker.js new file mode 100644 index 000000000..64433574b --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_history_tracker.js @@ -0,0 +1,251 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { PlacesDBUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesDBUtils.sys.mjs" +); +const { HistoryEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/history.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +let engine; +let tracker; + +add_task(async function setup() { + await Service.engineManager.clear(); + await Service.engineManager.register(HistoryEngine); + engine = Service.engineManager.get("history"); + tracker = engine._tracker; +}); + +async function verifyTrackerEmpty() { + let changes = await engine.pullNewChanges(); + do_check_empty(changes); + equal(tracker.score, 0); +} + +async function verifyTrackedCount(expected) { + let changes = await engine.pullNewChanges(); + do_check_attribute_count(changes, expected); +} + +async function verifyTrackedItems(tracked) { + let changes = await engine.pullNewChanges(); + let trackedIDs = new Set(Object.keys(changes)); + for (let guid of tracked) { + ok(guid in changes, `${guid} should be tracked`); + Assert.greater(changes[guid], 0, `${guid} should have a modified time`); + trackedIDs.delete(guid); + } + equal( + trackedIDs.size, + 0, + `Unhandled tracked IDs: ${JSON.stringify(Array.from(trackedIDs))}` + ); +} + +async function resetTracker() { + await tracker.clearChangedIDs(); + tracker.resetScore(); +} + +async function cleanup() { + await PlacesUtils.history.clear(); + await resetTracker(); + await tracker.stop(); +} + +add_task(async function test_empty() { + _("Verify we've got an empty, disabled tracker to work with."); + await verifyTrackerEmpty(); + Assert.ok(!tracker._isTracking); + + await cleanup(); +}); + +add_task(async function test_not_tracking() { + _("Create history item. Won't show because we haven't started tracking yet"); + await addVisit("not_tracking"); + await verifyTrackerEmpty(); + + await cleanup(); +}); + +add_task(async function test_start_tracking() { + _("Add hook for save completion."); + let savePromise = new Promise((resolve, reject) => { + let save = tracker._storage._save; + tracker._storage._save = async function () { + try { + await save.call(this); + resolve(); + } catch (ex) { + reject(ex); + } finally { + tracker._storage._save = save; + } + }; + }); + + _("Tell the tracker to start tracking changes."); + tracker.start(); + let scorePromise = promiseOneObserver("weave:engine:score:updated"); + await addVisit("start_tracking"); + await scorePromise; + + _("Score updated in test_start_tracking."); + await verifyTrackedCount(1); + Assert.equal(tracker.score, SCORE_INCREMENT_SMALL); + + await savePromise; + + _("changedIDs written to disk. Proceeding."); + await cleanup(); +}); + +add_task(async function test_start_tracking_twice() { + _("Verifying preconditions."); + tracker.start(); + await addVisit("start_tracking_twice1"); + await verifyTrackedCount(1); + Assert.equal(tracker.score, SCORE_INCREMENT_SMALL); + + _("Notifying twice won't do any harm."); + tracker.start(); + let scorePromise = promiseOneObserver("weave:engine:score:updated"); + await addVisit("start_tracking_twice2"); + await scorePromise; + + _("Score updated in test_start_tracking_twice."); + await verifyTrackedCount(2); + Assert.equal(tracker.score, 2 * SCORE_INCREMENT_SMALL); + + await cleanup(); +}); + +add_task(async function test_track_delete() { + _("Deletions are tracked."); + + // This isn't present because we weren't tracking when it was visited. + await addVisit("track_delete"); + let uri = CommonUtils.makeURI("http://getfirefox.com/track_delete"); + let guid = await engine._store.GUIDForUri(uri.spec); + await verifyTrackerEmpty(); + + tracker.start(); + let visitRemovedPromise = promiseVisit("removed", uri); + let scorePromise = promiseOneObserver("weave:engine:score:updated"); + await PlacesUtils.history.remove(uri); + await Promise.all([scorePromise, visitRemovedPromise]); + + await verifyTrackedItems([guid]); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + + await cleanup(); +}); + +add_task(async function test_dont_track_expiration() { + _("Expirations are not tracked."); + let uriToRemove = await addVisit("to_remove"); + let guidToRemove = await engine._store.GUIDForUri(uriToRemove.spec); + + await resetTracker(); + await verifyTrackerEmpty(); + + tracker.start(); + let visitRemovedPromise = promiseVisit("removed", uriToRemove); + let scorePromise = promiseOneObserver("weave:engine:score:updated"); + + // Observe expiration. + Services.obs.addObserver(function onExpiration(aSubject, aTopic) { + Services.obs.removeObserver(onExpiration, aTopic); + // Remove the remaining page to update its score. + PlacesUtils.history.remove(uriToRemove); + }, PlacesUtils.TOPIC_EXPIRATION_FINISHED); + + // Force expiration of 1 entry. + Services.prefs.setIntPref("places.history.expiration.max_pages", 0); + Cc["@mozilla.org/places/expiration;1"] + .getService(Ci.nsIObserver) + .observe(null, "places-debug-start-expiration", 1); + + await Promise.all([scorePromise, visitRemovedPromise]); + await verifyTrackedItems([guidToRemove]); + + await cleanup(); +}); + +add_task(async function test_stop_tracking() { + _("Let's stop tracking again."); + await tracker.stop(); + await addVisit("stop_tracking"); + await verifyTrackerEmpty(); + + await cleanup(); +}); + +add_task(async function test_stop_tracking_twice() { + await tracker.stop(); + await addVisit("stop_tracking_twice1"); + + _("Notifying twice won't do any harm."); + await tracker.stop(); + await addVisit("stop_tracking_twice2"); + await verifyTrackerEmpty(); + + await cleanup(); +}); + +add_task(async function test_filter_file_uris() { + tracker.start(); + + let uri = CommonUtils.makeURI("file:///Users/eoger/tps/config.json"); + let visitAddedPromise = promiseVisit("added", uri); + await PlacesTestUtils.addVisits({ + uri, + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_LINK, + }); + await visitAddedPromise; + + await verifyTrackerEmpty(); + await tracker.stop(); + await cleanup(); +}); + +add_task(async function test_filter_hidden() { + tracker.start(); + + _("Add visit; should be hidden by the redirect"); + let hiddenURI = await addVisit("hidden"); + let hiddenGUID = await engine._store.GUIDForUri(hiddenURI.spec); + _(`Hidden visit GUID: ${hiddenGUID}`); + + _("Add redirect visit; should be tracked"); + let trackedURI = await addVisit( + "redirect", + hiddenURI.spec, + PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT + ); + let trackedGUID = await engine._store.GUIDForUri(trackedURI.spec); + _(`Tracked visit GUID: ${trackedGUID}`); + + _("Add visit for framed link; should be ignored"); + let embedURI = await addVisit( + "framed_link", + null, + PlacesUtils.history.TRANSITION_FRAMED_LINK + ); + let embedGUID = await engine._store.GUIDForUri(embedURI.spec); + _(`Framed link visit GUID: ${embedGUID}`); + + _("Run Places maintenance to mark redirect visit as hidden"); + await PlacesDBUtils.maintenanceOnIdle(); + + await verifyTrackedItems([trackedGUID]); + + await cleanup(); +}); diff --git a/src/zen/tests/mochitests/sync/test_hmac_error.js b/src/zen/tests/mochitests/sync/test_hmac_error.js new file mode 100644 index 000000000..df1998d64 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_hmac_error.js @@ -0,0 +1,250 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +// Track HMAC error counts. +var hmacErrorCount = 0; +(function () { + let hHE = Service.handleHMACEvent; + Service.handleHMACEvent = async function () { + hmacErrorCount++; + return hHE.call(Service); + }; +})(); + +async function shared_setup() { + enableValidationPrefs(); + syncTestLogging(); + + hmacErrorCount = 0; + + let clientsEngine = Service.clientsEngine; + let clientsSyncID = await clientsEngine.resetLocalSyncID(); + + // Make sure RotaryEngine is the only one we sync. + let { engine, syncID, tracker } = await registerRotaryEngine(); + await engine.setLastSync(123); // Needs to be non-zero so that tracker is queried. + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + }; + await tracker.addChangedID("scotsman", 0); + Assert.equal(1, Service.engineManager.getEnabled().length); + + let engines = { + rotary: { version: engine.version, syncID }, + clients: { version: clientsEngine.version, syncID: clientsSyncID }, + }; + + // Common server objects. + let global = new ServerWBO("global", { engines }); + let keysWBO = new ServerWBO("keys"); + let rotaryColl = new ServerCollection({}, true); + let clientsColl = new ServerCollection({}, true); + + return [engine, rotaryColl, clientsColl, keysWBO, global, tracker]; +} + +add_task(async function hmac_error_during_404() { + _("Attempt to replicate the HMAC error setup."); + let [engine, rotaryColl, clientsColl, keysWBO, global, tracker] = + await shared_setup(); + + // Hand out 404s for crypto/keys. + let keysHandler = keysWBO.handler(); + let key404Counter = 0; + let keys404Handler = function (request, response) { + if (key404Counter > 0) { + let body = "Not Found"; + response.setStatusLine(request.httpVersion, 404, body); + response.bodyOutputStream.write(body, body.length); + key404Counter--; + return; + } + keysHandler(request, response); + }; + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let handlers = { + "/1.1/foo/info/collections": collectionsHelper.handler, + "/1.1/foo/storage/meta/global": upd("meta", global.handler()), + "/1.1/foo/storage/crypto/keys": upd("crypto", keys404Handler), + "/1.1/foo/storage/clients": upd("clients", clientsColl.handler()), + "/1.1/foo/storage/rotary": upd("rotary", rotaryColl.handler()), + }; + + let server = sync_httpd_setup(handlers); + // Do not instantiate SyncTestingInfrastructure; we need real crypto. + await configureIdentity({ username: "foo" }, server); + await Service.login(); + + try { + _("Syncing."); + await sync_and_validate_telem(); + + _( + "Partially resetting client, as if after a restart, and forcing redownload." + ); + Service.collectionKeys.clear(); + await engine.setLastSync(0); // So that we redownload records. + key404Counter = 1; + _("---------------------------"); + await sync_and_validate_telem(); + _("---------------------------"); + + // Two rotary items, one client record... no errors. + Assert.equal(hmacErrorCount, 0); + } finally { + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + await promiseStopServer(server); + } +}); + +add_task(async function hmac_error_during_node_reassignment() { + _("Attempt to replicate an HMAC error during node reassignment."); + let [engine, rotaryColl, clientsColl, keysWBO, global, tracker] = + await shared_setup(); + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + // We'll provide a 401 mid-way through the sync. This function + // simulates shifting to a node which has no data. + function on401() { + _("Deleting server data..."); + global.delete(); + rotaryColl.delete(); + keysWBO.delete(); + clientsColl.delete(); + delete collectionsHelper.collections.rotary; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.clients; + _("Deleted server data."); + } + + let should401 = false; + function upd401(coll, handler) { + return function (request, response) { + if (should401 && request.method != "DELETE") { + on401(); + should401 = false; + let body = '"reassigned!"'; + response.setStatusLine(request.httpVersion, 401, "Node reassignment."); + response.bodyOutputStream.write(body, body.length); + return; + } + handler(request, response); + }; + } + + let handlers = { + "/1.1/foo/info/collections": collectionsHelper.handler, + "/1.1/foo/storage/meta/global": upd("meta", global.handler()), + "/1.1/foo/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/foo/storage/clients": upd401("clients", clientsColl.handler()), + "/1.1/foo/storage/rotary": upd("rotary", rotaryColl.handler()), + }; + + let server = sync_httpd_setup(handlers); + // Do not instantiate SyncTestingInfrastructure; we need real crypto. + await configureIdentity({ username: "foo" }, server); + + _("Syncing."); + // First hit of clients will 401. This will happen after meta/global and + // keys -- i.e., in the middle of the sync, but before RotaryEngine. + should401 = true; + + // Use observers to perform actions when our sync finishes. + // This allows us to observe the automatic next-tick sync that occurs after + // an abort. + function onSyncError() { + do_throw("Should not get a sync error!"); + } + let onSyncFinished = function () {}; + let obs = { + observe: function observe(subject, topic) { + switch (topic) { + case "weave:service:sync:error": + onSyncError(); + break; + case "weave:service:sync:finish": + onSyncFinished(); + break; + } + }, + }; + + Svc.Obs.add("weave:service:sync:finish", obs); + Svc.Obs.add("weave:service:sync:error", obs); + + // This kicks off the actual test. Split into a function here to allow this + // source file to broadly follow actual execution order. + async function onwards() { + _("== Invoking first sync."); + await Service.sync(); + _("We should not simultaneously have data but no keys on the server."); + let hasData = rotaryColl.wbo("flying") || rotaryColl.wbo("scotsman"); + let hasKeys = keysWBO.modified; + + _("We correctly handle 401s by aborting the sync and starting again."); + Assert.equal(!hasData, !hasKeys); + + _("Be prepared for the second (automatic) sync..."); + } + + _("Make sure that syncing again causes recovery."); + let callbacksPromise = new Promise(resolve => { + onSyncFinished = function () { + _("== First sync done."); + _("---------------------------"); + onSyncFinished = function () { + _("== Second (automatic) sync done."); + let hasData = rotaryColl.wbo("flying") || rotaryColl.wbo("scotsman"); + let hasKeys = keysWBO.modified; + Assert.equal(!hasData, !hasKeys); + + // Kick off another sync. Can't just call it, because we're inside the + // lock... + (async () => { + await Async.promiseYield(); + _("Now a fresh sync will get no HMAC errors."); + _( + "Partially resetting client, as if after a restart, and forcing redownload." + ); + Service.collectionKeys.clear(); + await engine.setLastSync(0); + hmacErrorCount = 0; + + onSyncFinished = async function () { + // Two rotary items, one client record... no errors. + Assert.equal(hmacErrorCount, 0); + + Svc.Obs.remove("weave:service:sync:finish", obs); + Svc.Obs.remove("weave:service:sync:error", obs); + + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + server.stop(resolve); + }; + + Service.sync(); + })().catch(console.error); + }; + }; + }); + await onwards(); + await callbacksPromise; +}); diff --git a/src/zen/tests/mochitests/sync/test_httpd_sync_server.js b/src/zen/tests/mochitests/sync/test_httpd_sync_server.js new file mode 100644 index 000000000..0cc489082 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_httpd_sync_server.js @@ -0,0 +1,250 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_test(function test_creation() { + // Explicit callback for this one. + let server = new SyncServer(Object.create(SyncServerCallback)); + Assert.ok(!!server); // Just so we have a check. + server.start(null, function () { + _("Started on " + server.port); + server.stop(run_next_test); + }); +}); + +add_test(function test_url_parsing() { + let server = new SyncServer(); + + // Check that we can parse a WBO URI. + let parts = server.pathRE.exec("/1.1/johnsmith/storage/crypto/keys"); + let [all, version, username, first, rest] = parts; + Assert.equal(all, "/1.1/johnsmith/storage/crypto/keys"); + Assert.equal(version, "1.1"); + Assert.equal(username, "johnsmith"); + Assert.equal(first, "storage"); + Assert.equal(rest, "crypto/keys"); + Assert.equal(null, server.pathRE.exec("/nothing/else")); + + // Check that we can parse a collection URI. + parts = server.pathRE.exec("/1.1/johnsmith/storage/crypto"); + [all, version, username, first, rest] = parts; + Assert.equal(all, "/1.1/johnsmith/storage/crypto"); + Assert.equal(version, "1.1"); + Assert.equal(username, "johnsmith"); + Assert.equal(first, "storage"); + Assert.equal(rest, "crypto"); + + // We don't allow trailing slash on storage URI. + parts = server.pathRE.exec("/1.1/johnsmith/storage/"); + Assert.equal(parts, undefined); + + // storage alone is a valid request. + parts = server.pathRE.exec("/1.1/johnsmith/storage"); + [all, version, username, first, rest] = parts; + Assert.equal(all, "/1.1/johnsmith/storage"); + Assert.equal(version, "1.1"); + Assert.equal(username, "johnsmith"); + Assert.equal(first, "storage"); + Assert.equal(rest, undefined); + + parts = server.storageRE.exec("storage"); + let collection; + [all, , collection] = parts; + Assert.equal(all, "storage"); + Assert.equal(collection, undefined); + + run_next_test(); +}); + +const { RESTRequest } = ChromeUtils.importESModule( + "resource://services-common/rest.sys.mjs" +); +function localRequest(server, path) { + _("localRequest: " + path); + let url = server.baseURI.substr(0, server.baseURI.length - 1) + path; + _("url: " + url); + return new RESTRequest(url); +} + +add_task(async function test_basic_http() { + let server = new SyncServer(); + server.registerUser("john", "password"); + Assert.ok(server.userExists("john")); + server.start(); + _("Started on " + server.port); + + let req = localRequest(server, "/1.1/john/storage/crypto/keys"); + _("req is " + req); + // Shouldn't reject, beyond that we don't care. + await req.get(); + + await promiseStopServer(server); +}); + +add_task(async function test_info_collections() { + let server = new SyncServer(Object.create(SyncServerCallback)); + function responseHasCorrectHeaders(r) { + Assert.equal(r.status, 200); + Assert.equal(r.headers["content-type"], "application/json"); + Assert.ok("x-weave-timestamp" in r.headers); + } + + server.registerUser("john", "password"); + server.start(); + + let req = localRequest(server, "/1.1/john/info/collections"); + await req.get(); + responseHasCorrectHeaders(req.response); + Assert.equal(req.response.body, "{}"); + + let putReq = localRequest(server, "/1.1/john/storage/crypto/keys"); + let payload = JSON.stringify({ foo: "bar" }); + let putResp = await putReq.put(payload); + + responseHasCorrectHeaders(putResp); + + let putResponseBody = putResp.body; + _("PUT response body: " + JSON.stringify(putResponseBody)); + + // When we PUT something to crypto/keys, "crypto" appears in the response. + req = localRequest(server, "/1.1/john/info/collections"); + + await req.get(); + responseHasCorrectHeaders(req.response); + let expectedColl = server.getCollection("john", "crypto"); + Assert.ok(!!expectedColl); + let modified = expectedColl.timestamp; + Assert.greater(modified, 0); + Assert.equal(putResponseBody, modified); + Assert.equal(JSON.parse(req.response.body).crypto, modified); + + await promiseStopServer(server); +}); + +add_task(async function test_storage_request() { + let keysURL = "/1.1/john/storage/crypto/keys?foo=bar"; + let foosURL = "/1.1/john/storage/crypto/foos"; + let storageURL = "/1.1/john/storage"; + + let server = new SyncServer(); + let creation = server.timestamp(); + server.registerUser("john", "password"); + + server.createContents("john", { + crypto: { foos: { foo: "bar" } }, + }); + let coll = server.user("john").collection("crypto"); + Assert.ok(!!coll); + + _("We're tracking timestamps."); + Assert.greaterOrEqual(coll.timestamp, creation); + + async function retrieveWBONotExists() { + let req = localRequest(server, keysURL); + let response = await req.get(); + _("Body is " + response.body); + _("Modified is " + response.newModified); + Assert.equal(response.status, 404); + Assert.equal(response.body, "Not found"); + } + + async function retrieveWBOExists() { + let req = localRequest(server, foosURL); + let response = await req.get(); + _("Body is " + response.body); + _("Modified is " + response.newModified); + let parsedBody = JSON.parse(response.body); + Assert.equal(parsedBody.id, "foos"); + Assert.equal(parsedBody.modified, coll.wbo("foos").modified); + Assert.equal(JSON.parse(parsedBody.payload).foo, "bar"); + } + + async function deleteWBONotExists() { + let req = localRequest(server, keysURL); + server.callback.onItemDeleted = function () { + do_throw("onItemDeleted should not have been called."); + }; + + let response = await req.delete(); + + _("Body is " + response.body); + _("Modified is " + response.newModified); + Assert.equal(response.status, 200); + delete server.callback.onItemDeleted; + } + + async function deleteWBOExists() { + let req = localRequest(server, foosURL); + server.callback.onItemDeleted = function (username, collection, wboID) { + _("onItemDeleted called for " + collection + "/" + wboID); + delete server.callback.onItemDeleted; + Assert.equal(username, "john"); + Assert.equal(collection, "crypto"); + Assert.equal(wboID, "foos"); + }; + await req.delete(); + _("Body is " + req.response.body); + _("Modified is " + req.response.newModified); + Assert.equal(req.response.status, 200); + } + + async function deleteStorage() { + _("Testing DELETE on /storage."); + let now = server.timestamp(); + _("Timestamp: " + now); + let req = localRequest(server, storageURL); + await req.delete(); + + _("Body is " + req.response.body); + _("Modified is " + req.response.newModified); + let parsedBody = JSON.parse(req.response.body); + Assert.greaterOrEqual(parsedBody, now); + do_check_empty(server.users.john.collections); + } + + async function getStorageFails() { + _("Testing that GET on /storage fails."); + let req = localRequest(server, storageURL); + await req.get(); + Assert.equal(req.response.status, 405); + Assert.equal(req.response.headers.allow, "DELETE"); + } + + async function getMissingCollectionWBO() { + _("Testing that fetching a WBO from an on-existent collection 404s."); + let req = localRequest(server, storageURL + "/foobar/baz"); + await req.get(); + Assert.equal(req.response.status, 404); + } + + server.start(null); + + await retrieveWBONotExists(); + await retrieveWBOExists(); + await deleteWBOExists(); + await deleteWBONotExists(); + await getStorageFails(); + await getMissingCollectionWBO(); + await deleteStorage(); + + await promiseStopServer(server); +}); + +add_task(async function test_x_weave_records() { + let server = new SyncServer(); + server.registerUser("john", "password"); + + server.createContents("john", { + crypto: { foos: { foo: "bar" }, bars: { foo: "baz" } }, + }); + server.start(); + + let wbo = localRequest(server, "/1.1/john/storage/crypto/foos"); + await wbo.get(); + Assert.equal(false, "x-weave-records" in wbo.response.headers); + let col = localRequest(server, "/1.1/john/storage/crypto"); + await col.get(); + // Collection fetches do. + Assert.equal(col.response.headers["x-weave-records"], "2"); + + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_interval_triggers.js b/src/zen/tests/mochitests/sync/test_interval_triggers.js new file mode 100644 index 000000000..a2a35fb3f --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_interval_triggers.js @@ -0,0 +1,472 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Svc.PrefBranch.setStringPref("registerEngines", ""); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +let scheduler; +let clientsEngine; + +async function sync_httpd_setup() { + let clientsSyncID = await clientsEngine.resetLocalSyncID(); + let global = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { + clients: { version: clientsEngine.version, syncID: clientsSyncID }, + }, + }); + let clientsColl = new ServerCollection({}, true); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + return httpd_setup({ + "/1.1/johndoe/storage/meta/global": upd("meta", global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()), + }); +} + +async function setUp(server) { + syncTestLogging(); + await configureIdentity({ username: "johndoe" }, server); + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + await serverKeys.upload(Service.resource(Service.cryptoKeysURL)); +} + +add_task(async function setup() { + scheduler = Service.scheduler; + clientsEngine = Service.clientsEngine; + + // Don't remove stale clients when syncing. This is a test-only workaround + // that lets us add clients directly to the store, without losing them on + // the next sync. + clientsEngine._removeRemoteClient = async () => {}; +}); + +add_task(async function test_successful_sync_adjustSyncInterval() { + enableValidationPrefs(); + + _("Test successful sync calling adjustSyncInterval"); + let syncSuccesses = 0; + function onSyncFinish() { + _("Sync success."); + syncSuccesses++; + } + Svc.Obs.add("weave:service:sync:finish", onSyncFinish); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Confirm defaults + Assert.ok(!scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.ok(!scheduler.hasIncomingItems); + + _("Test as long as numClients <= 1 our sync interval is SINGLE_USER."); + // idle == true && numClients <= 1 && hasIncomingItems == false + scheduler.idle = true; + await Service.sync(); + Assert.equal(syncSuccesses, 1); + Assert.ok(scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == false + scheduler.idle = false; + await Service.sync(); + Assert.equal(syncSuccesses, 2); + Assert.ok(!scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + await Service.sync(); + Assert.equal(syncSuccesses, 3); + Assert.ok(!scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == true && numClients <= 1 && hasIncomingItems == true + scheduler.idle = true; + await Service.sync(); + Assert.equal(syncSuccesses, 4); + Assert.ok(scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + _( + "Test as long as idle && numClients > 1 our sync interval is idleInterval." + ); + // idle == true && numClients > 1 && hasIncomingItems == true + await Service.clientsEngine._store.create({ + id: "foo", + cleartext: { name: "bar", type: "mobile" }, + }); + await Service.sync(); + Assert.equal(syncSuccesses, 5); + Assert.ok(scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.idleInterval); + + // idle == true && numClients > 1 && hasIncomingItems == false + scheduler.hasIncomingItems = false; + await Service.sync(); + Assert.equal(syncSuccesses, 6); + Assert.ok(scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.idleInterval); + + _("Test non-idle, numClients > 1, no incoming items => activeInterval."); + // idle == false && numClients > 1 && hasIncomingItems == false + scheduler.idle = false; + await Service.sync(); + Assert.equal(syncSuccesses, 7); + Assert.ok(!scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + + _("Test non-idle, numClients > 1, incoming items => immediateInterval."); + // idle == false && numClients > 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + await Service.sync(); + Assert.equal(syncSuccesses, 8); + Assert.ok(!scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.hasIncomingItems); // gets reset to false + Assert.equal(scheduler.syncInterval, scheduler.immediateInterval); + + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_unsuccessful_sync_adjustSyncInterval() { + enableValidationPrefs(); + + _("Test unsuccessful sync calling adjustSyncInterval"); + + let syncFailures = 0; + function onSyncError() { + _("Sync error."); + syncFailures++; + } + Svc.Obs.add("weave:service:sync:error", onSyncError); + + _("Test unsuccessful sync calls adjustSyncInterval"); + // Force sync to fail. + Svc.PrefBranch.setStringPref("firstSync", "notReady"); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Confirm defaults + Assert.ok(!scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.ok(!scheduler.hasIncomingItems); + + _("Test as long as numClients <= 1 our sync interval is SINGLE_USER."); + // idle == true && numClients <= 1 && hasIncomingItems == false + scheduler.idle = true; + await Service.sync(); + Assert.equal(syncFailures, 1); + Assert.ok(scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == false + scheduler.idle = false; + await Service.sync(); + Assert.equal(syncFailures, 2); + Assert.ok(!scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == false && numClients <= 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + await Service.sync(); + Assert.equal(syncFailures, 3); + Assert.ok(!scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // idle == true && numClients <= 1 && hasIncomingItems == true + scheduler.idle = true; + await Service.sync(); + Assert.equal(syncFailures, 4); + Assert.ok(scheduler.idle); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + _( + "Test as long as idle && numClients > 1 our sync interval is idleInterval." + ); + // idle == true && numClients > 1 && hasIncomingItems == true + Svc.PrefBranch.setIntPref("clients.devices.mobile", 2); + scheduler.updateClientMode(); + + await Service.sync(); + Assert.equal(syncFailures, 5); + Assert.ok(scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.idleInterval); + + // idle == true && numClients > 1 && hasIncomingItems == false + scheduler.hasIncomingItems = false; + await Service.sync(); + Assert.equal(syncFailures, 6); + Assert.ok(scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.idleInterval); + + _("Test non-idle, numClients > 1, no incoming items => activeInterval."); + // idle == false && numClients > 1 && hasIncomingItems == false + scheduler.idle = false; + await Service.sync(); + Assert.equal(syncFailures, 7); + Assert.ok(!scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + + _("Test non-idle, numClients > 1, incoming items => immediateInterval."); + // idle == false && numClients > 1 && hasIncomingItems == true + scheduler.hasIncomingItems = true; + await Service.sync(); + Assert.equal(syncFailures, 8); + Assert.ok(!scheduler.idle); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.hasIncomingItems); // gets reset to false + Assert.equal(scheduler.syncInterval, scheduler.immediateInterval); + + await Service.startOver(); + Svc.Obs.remove("weave:service:sync:error", onSyncError); + await promiseStopServer(server); +}); + +add_task(async function test_back_triggers_sync() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Single device: no sync triggered. + scheduler.idle = true; + scheduler.observe( + null, + "active", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.ok(!scheduler.idle); + + // Multiple devices: sync is triggered. + Svc.PrefBranch.setIntPref("clients.devices.mobile", 2); + scheduler.updateClientMode(); + + let promiseDone = promiseOneObserver("weave:service:sync:finish"); + + scheduler.idle = true; + scheduler.observe( + null, + "active", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.ok(!scheduler.idle); + await promiseDone; + + Service.recordManager.clearCache(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + scheduler.setDefaults(); + await clientsEngine.resetClient(); + + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_adjust_interval_on_sync_error() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + let syncFailures = 0; + function onSyncError() { + _("Sync error."); + syncFailures++; + } + Svc.Obs.add("weave:service:sync:error", onSyncError); + + _("Test unsuccessful sync updates client mode & sync intervals"); + // Force a sync fail. + Svc.PrefBranch.setStringPref("firstSync", "notReady"); + + Assert.equal(syncFailures, 0); + Assert.equal(false, scheduler.numClients > 1); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + Svc.PrefBranch.setIntPref("clients.devices.mobile", 2); + await Service.sync(); + + Assert.equal(syncFailures, 1); + Assert.greater(scheduler.numClients, 1); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + + Svc.Obs.remove("weave:service:sync:error", onSyncError); + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_bug671378_scenario() { + enableValidationPrefs(); + + // Test scenario similar to bug 671378. This bug appeared when a score + // update occurred that wasn't large enough to trigger a sync so + // scheduleNextSync() was called without a time interval parameter, + // setting nextSync to a non-zero value and preventing the timer from + // being adjusted in the next call to scheduleNextSync(). + let server = await sync_httpd_setup(); + await setUp(server); + + let syncSuccesses = 0; + function onSyncFinish() { + _("Sync success."); + syncSuccesses++; + } + Svc.Obs.add("weave:service:sync:finish", onSyncFinish); + + // After first sync call, syncInterval & syncTimer are singleDeviceInterval. + await Service.sync(); + Assert.equal(syncSuccesses, 1); + Assert.equal(false, scheduler.numClients > 1); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.equal(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + + let promiseDone = new Promise(resolve => { + // Wrap scheduleNextSync so we are notified when it is finished. + scheduler._scheduleNextSync = scheduler.scheduleNextSync; + scheduler.scheduleNextSync = function () { + scheduler._scheduleNextSync(); + + // Check on sync:finish scheduleNextSync sets the appropriate + // syncInterval and syncTimer values. + if (syncSuccesses == 2) { + Assert.notEqual(scheduler.nextSync, 0); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, scheduler.activeInterval); + + scheduler.scheduleNextSync = scheduler._scheduleNextSync; + Svc.Obs.remove("weave:service:sync:finish", onSyncFinish); + Service.startOver().then(() => { + server.stop(resolve); + }); + } + }; + }); + + // Set nextSync != 0 + // syncInterval still hasn't been set by call to updateClientMode. + // Explicitly trying to invoke scheduleNextSync during a sync + // (to immitate a score update that isn't big enough to trigger a sync). + Svc.Obs.add("weave:service:sync:start", function onSyncStart() { + // Wait for other sync:start observers to be called so that + // nextSync is set to 0. + CommonUtils.nextTick(function () { + Svc.Obs.remove("weave:service:sync:start", onSyncStart); + + scheduler.scheduleNextSync(); + Assert.notEqual(scheduler.nextSync, 0); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.equal(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + }); + }); + + await Service.clientsEngine._store.create({ + id: "foo", + cleartext: { name: "bar", type: "mobile" }, + }); + await Service.sync(); + await promiseDone; +}); + +add_task(async function test_adjust_timer_larger_syncInterval() { + _( + "Test syncInterval > current timout period && nextSync != 0, syncInterval is NOT used." + ); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 2); + scheduler.updateClientMode(); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + + scheduler.scheduleNextSync(); + + // Ensure we have a small interval. + Assert.notEqual(scheduler.nextSync, 0); + Assert.equal(scheduler.syncTimer.delay, scheduler.activeInterval); + + // Make interval large again + await clientsEngine._wipeClient(); + Svc.PrefBranch.clearUserPref("clients.devices.mobile"); + scheduler.updateClientMode(); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + scheduler.scheduleNextSync(); + + // Ensure timer delay remains as the small interval. + Assert.notEqual(scheduler.nextSync, 0); + Assert.lessOrEqual(scheduler.syncTimer.delay, scheduler.activeInterval); + + // SyncSchedule. + await Service.startOver(); +}); + +add_task(async function test_adjust_timer_smaller_syncInterval() { + _( + "Test current timout > syncInterval period && nextSync != 0, syncInterval is used." + ); + scheduler.scheduleNextSync(); + + // Ensure we have a large interval. + Assert.notEqual(scheduler.nextSync, 0); + Assert.equal(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + + // Make interval smaller + Svc.PrefBranch.setIntPref("clients.devices.mobile", 2); + scheduler.updateClientMode(); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + + scheduler.scheduleNextSync(); + + // Ensure smaller timer delay is used. + Assert.notEqual(scheduler.nextSync, 0); + Assert.lessOrEqual(scheduler.syncTimer.delay, scheduler.activeInterval); + + // SyncSchedule. + await Service.startOver(); +}); diff --git a/src/zen/tests/mochitests/sync/test_keys.js b/src/zen/tests/mochitests/sync/test_keys.js new file mode 100644 index 000000000..58453bfba --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_keys.js @@ -0,0 +1,242 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); +const { CollectionKeyManager, CryptoWrapper } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); + +var collectionKeys = new CollectionKeyManager(); + +function do_check_keypair_eq(a, b) { + Assert.equal(2, a.length); + Assert.equal(2, b.length); + Assert.equal(a[0], b[0]); + Assert.equal(a[1], b[1]); +} + +add_test(function test_set_invalid_values() { + _("Ensure that setting invalid encryption and HMAC key values is caught."); + + let bundle = new BulkKeyBundle("foo"); + + let thrown = false; + try { + bundle.encryptionKey = null; + } catch (ex) { + thrown = true; + Assert.equal(ex.message.indexOf("Encryption key can only be set to"), 0); + } finally { + Assert.ok(thrown); + thrown = false; + } + + try { + bundle.encryptionKey = ["trollololol"]; + } catch (ex) { + thrown = true; + Assert.equal(ex.message.indexOf("Encryption key can only be set to"), 0); + } finally { + Assert.ok(thrown); + thrown = false; + } + + try { + bundle.hmacKey = Utils.generateRandomBytesLegacy(15); + } catch (ex) { + thrown = true; + Assert.equal(ex.message.indexOf("HMAC key must be at least 128"), 0); + } finally { + Assert.ok(thrown); + thrown = false; + } + + try { + bundle.hmacKey = null; + } catch (ex) { + thrown = true; + Assert.equal(ex.message.indexOf("HMAC key can only be set to string"), 0); + } finally { + Assert.ok(thrown); + thrown = false; + } + + try { + bundle.hmacKey = ["trollolol"]; + } catch (ex) { + thrown = true; + Assert.equal(ex.message.indexOf("HMAC key can only be set to"), 0); + } finally { + Assert.ok(thrown); + thrown = false; + } + + try { + bundle.hmacKey = Utils.generateRandomBytesLegacy(15); + } catch (ex) { + thrown = true; + Assert.equal(ex.message.indexOf("HMAC key must be at least 128"), 0); + } finally { + Assert.ok(thrown); + thrown = false; + } + + run_next_test(); +}); + +add_task(async function test_ensureLoggedIn() { + let log = Log.repository.getLogger("Test"); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + await configureIdentity(); + + let keyBundle = Weave.Service.identity.syncKeyBundle; + + /* + * Build a test version of storage/crypto/keys. + * Encrypt it with the sync key. + * Pass it into the CollectionKeyManager. + */ + + log.info("Building storage keys..."); + let storage_keys = new CryptoWrapper("crypto", "keys"); + let default_key64 = await Weave.Crypto.generateRandomKey(); + let default_hmac64 = await Weave.Crypto.generateRandomKey(); + let bookmarks_key64 = await Weave.Crypto.generateRandomKey(); + let bookmarks_hmac64 = await Weave.Crypto.generateRandomKey(); + + storage_keys.cleartext = { + default: [default_key64, default_hmac64], + collections: { bookmarks: [bookmarks_key64, bookmarks_hmac64] }, + }; + storage_keys.modified = Date.now() / 1000; + storage_keys.id = "keys"; + + log.info("Encrypting storage keys..."); + + // Use passphrase (sync key) itself to encrypt the key bundle. + await storage_keys.encrypt(keyBundle); + + // Sanity checking. + Assert.equal(null, storage_keys.cleartext); + Assert.notEqual(null, storage_keys.ciphertext); + + log.info("Updating collection keys."); + + // updateContents decrypts the object, releasing the payload for us to use. + // Returns true, because the default key has changed. + Assert.ok(await collectionKeys.updateContents(keyBundle, storage_keys)); + let payload = storage_keys.cleartext; + + _("CK: " + JSON.stringify(collectionKeys._collections)); + + // Test that the CollectionKeyManager returns a similar WBO. + let wbo = collectionKeys.asWBO("crypto", "keys"); + + _("WBO: " + JSON.stringify(wbo)); + _("WBO cleartext: " + JSON.stringify(wbo.cleartext)); + + // Check the individual contents. + Assert.equal(wbo.collection, "crypto"); + Assert.equal(wbo.id, "keys"); + Assert.equal(undefined, wbo.modified); + Assert.equal(collectionKeys.lastModified, storage_keys.modified); + Assert.ok(!!wbo.cleartext.default); + do_check_keypair_eq(payload.default, wbo.cleartext.default); + do_check_keypair_eq( + payload.collections.bookmarks, + wbo.cleartext.collections.bookmarks + ); + + Assert.ok("bookmarks" in collectionKeys._collections); + Assert.equal(false, "tabs" in collectionKeys._collections); + + _("Updating contents twice with the same data doesn't proceed."); + await storage_keys.encrypt(keyBundle); + Assert.equal( + false, + await collectionKeys.updateContents(keyBundle, storage_keys) + ); + + /* + * Test that we get the right keys out when we ask for + * a collection's tokens. + */ + let b1 = new BulkKeyBundle("bookmarks"); + b1.keyPairB64 = [bookmarks_key64, bookmarks_hmac64]; + let b2 = collectionKeys.keyForCollection("bookmarks"); + do_check_keypair_eq(b1.keyPair, b2.keyPair); + + // Check key equality. + Assert.ok(b1.equals(b2)); + Assert.ok(b2.equals(b1)); + + b1 = new BulkKeyBundle("[default]"); + b1.keyPairB64 = [default_key64, default_hmac64]; + + Assert.ok(!b1.equals(b2)); + Assert.ok(!b2.equals(b1)); + + b2 = collectionKeys.keyForCollection(null); + do_check_keypair_eq(b1.keyPair, b2.keyPair); + + /* + * Checking for update times. + */ + let info_collections = {}; + Assert.ok(collectionKeys.updateNeeded(info_collections)); + info_collections.crypto = 5000; + Assert.ok(!collectionKeys.updateNeeded(info_collections)); + info_collections.crypto = 1 + Date.now() / 1000; // Add one in case computers are fast! + Assert.ok(collectionKeys.updateNeeded(info_collections)); + + collectionKeys.lastModified = null; + Assert.ok(collectionKeys.updateNeeded({})); + + /* + * Check _compareKeyBundleCollections. + */ + async function newBundle(name) { + let r = new BulkKeyBundle(name); + await r.generateRandom(); + return r; + } + let k1 = await newBundle("k1"); + let k2 = await newBundle("k2"); + let k3 = await newBundle("k3"); + let k4 = await newBundle("k4"); + let k5 = await newBundle("k5"); + let coll1 = { foo: k1, bar: k2 }; + let coll2 = { foo: k1, bar: k2 }; + let coll3 = { foo: k1, bar: k3 }; + let coll4 = { foo: k4 }; + let coll5 = { baz: k5, bar: k2 }; + let coll6 = {}; + + let d1 = collectionKeys._compareKeyBundleCollections(coll1, coll2); // [] + let d2 = collectionKeys._compareKeyBundleCollections(coll1, coll3); // ["bar"] + let d3 = collectionKeys._compareKeyBundleCollections(coll3, coll2); // ["bar"] + let d4 = collectionKeys._compareKeyBundleCollections(coll1, coll4); // ["bar", "foo"] + let d5 = collectionKeys._compareKeyBundleCollections(coll5, coll2); // ["baz", "foo"] + let d6 = collectionKeys._compareKeyBundleCollections(coll6, coll1); // ["bar", "foo"] + let d7 = collectionKeys._compareKeyBundleCollections(coll5, coll5); // [] + let d8 = collectionKeys._compareKeyBundleCollections(coll6, coll6); // [] + + Assert.ok(d1.same); + Assert.ok(!d2.same); + Assert.ok(!d3.same); + Assert.ok(!d4.same); + Assert.ok(!d5.same); + Assert.ok(!d6.same); + Assert.ok(d7.same); + Assert.ok(d8.same); + + Assert.deepEqual(d1.changed, []); + Assert.deepEqual(d2.changed, ["bar"]); + Assert.deepEqual(d3.changed, ["bar"]); + Assert.deepEqual(d4.changed, ["bar", "foo"]); + Assert.deepEqual(d5.changed, ["baz", "foo"]); + Assert.deepEqual(d6.changed, ["bar", "foo"]); +}); diff --git a/src/zen/tests/mochitests/sync/test_load_modules.js b/src/zen/tests/mochitests/sync/test_load_modules.js new file mode 100644 index 000000000..93ef883d4 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_load_modules.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const modules = [ + "addonutils.sys.mjs", + "addonsreconciler.sys.mjs", + "constants.sys.mjs", + "engines/addons.sys.mjs", + "engines/clients.sys.mjs", + "engines/extension-storage.sys.mjs", + "engines/passwords.sys.mjs", + "engines/prefs.sys.mjs", + "engines.sys.mjs", + "keys.sys.mjs", + "main.sys.mjs", + "policies.sys.mjs", + "record.sys.mjs", + "resource.sys.mjs", + "service.sys.mjs", + "stages/declined.sys.mjs", + "stages/enginesync.sys.mjs", + "status.sys.mjs", + "sync_auth.sys.mjs", + "util.sys.mjs", +]; + +if (AppConstants.MOZ_APP_NAME != "thunderbird") { + modules.push( + "engines/bookmarks.sys.mjs", + "engines/forms.sys.mjs", + "engines/history.sys.mjs", + "engines/tabs.sys.mjs" + ); +} + +const testingModules = [ + "fakeservices.sys.mjs", + "rotaryengine.sys.mjs", + "utils.sys.mjs", + "fxa_utils.sys.mjs", +]; + +function run_test() { + for (let m of modules) { + let res = "resource://services-sync/" + m; + _("Attempting to load " + res); + ChromeUtils.importESModule(res); + } + + for (let m of testingModules) { + let res = "resource://testing-common/services/sync/" + m; + _("Attempting to load " + res); + ChromeUtils.importESModule(res); + } +} diff --git a/src/zen/tests/mochitests/sync/test_node_reassignment.js b/src/zen/tests/mochitests/sync/test_node_reassignment.js new file mode 100644 index 000000000..4a10cb803 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_node_reassignment.js @@ -0,0 +1,523 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +_( + "Test that node reassignment responses are respected on all kinds of " + + "requests." +); + +const { RESTRequest } = ChromeUtils.importESModule( + "resource://services-common/rest.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function setup() { + validate_all_future_pings(); +}); + +/** + * Emulate the following Zeus config: + * $draining = data.get($prefix . $host . " draining"); + * if ($draining == "drain.") { + * log.warn($log_host_db_status . " migrating=1 (node-reassignment)" . + * $log_suffix); + * http.sendResponse("401 Node reassignment", $content_type, + * '"server request: node reassignment"', ""); + * } + */ +const reassignBody = '"server request: node reassignment"'; + +// API-compatible with SyncServer handler. Bind `handler` to something to use +// as a ServerCollection handler. +function handleReassign(handler, req, resp) { + resp.setStatusLine(req.httpVersion, 401, "Node reassignment"); + resp.setHeader("Content-Type", "application/json"); + resp.bodyOutputStream.write(reassignBody, reassignBody.length); +} + +async function prepareServer() { + let server = new SyncServer(); + server.registerUser("johndoe"); + server.start(); + syncTestLogging(); + await configureIdentity({ username: "johndoe" }, server); + return server; +} + +function getReassigned() { + try { + return Services.prefs.getBoolPref("services.sync.lastSyncReassigned"); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_UNEXPECTED) { + do_throw( + "Got exception retrieving lastSyncReassigned: " + Log.exceptionStr(ex) + ); + } + } + return false; +} + +/** + * Make a test request to `url`, then watch the result of two syncs + * to ensure that a node request was made. + * Runs `between` between the two. This can be used to undo deliberate failure + * setup, detach observers, etc. + */ +async function syncAndExpectNodeReassignment( + server, + firstNotification, + between, + secondNotification, + url +) { + let deferred = Promise.withResolvers(); + + let getTokenCount = 0; + let mockTSC = { + // TokenServerClient + async getTokenUsingOAuth() { + getTokenCount++; + return { endpoint: server.baseURI + "1.1/johndoe/" }; + }, + }; + Service.identity._tokenServerClient = mockTSC; + + // Make sure that it works! + let request = new RESTRequest(url); + let response = await request.get(); + Assert.equal(response.status, 401); + + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + Assert.equal(Service.clusterURL, ""); + + // Allow for tests to clean up error conditions. + between(); + } + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Second sync nextTick."); + Assert.equal(getTokenCount, 1); + Service.startOver().then(() => { + server.stop(deferred.resolve); + }); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + await Service.sync(); + + await deferred.promise; +} + +add_task(async function test_momentary_401_engine() { + enableValidationPrefs(); + + _("Test a failure for engine URLs that's resolved by reassignment."); + let server = await prepareServer(); + let john = server.user("johndoe"); + + _("Enabling the Rotary engine."); + let { engine, syncID, tracker } = await registerRotaryEngine(); + + // We need the server to be correctly set up prior to experimenting. Do this + // through a sync. + let global = { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + rotary: { version: engine.version, syncID }, + }; + john.createCollection("meta").insert("global", global); + + _("First sync to prepare server contents."); + await Service.sync(); + + let numResets = 0; + let observeReset = (obs, topic) => { + if (topic == "rotary") { + numResets += 1; + } + }; + _("Adding observer that we saw an engine reset."); + Svc.Obs.add("weave:engine:reset-client:finish", observeReset); + + _("Setting up Rotary collection to 401."); + let rotary = john.createCollection("rotary"); + let oldHandler = rotary.collectionHandler; + rotary.collectionHandler = handleReassign.bind(this, undefined); + + // We want to verify that the clusterURL pref has been cleared after a 401 + // inside a sync. Flag the Rotary engine to need syncing. + john.collection("rotary").timestamp += 1000; + + function between() { + _("Undoing test changes."); + rotary.collectionHandler = oldHandler; + + function onLoginStart() { + // lastSyncReassigned shouldn't be cleared until a sync has succeeded. + _("Ensuring that lastSyncReassigned is still set at next sync start."); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + Assert.ok(getReassigned()); + } + + _("Adding observer that lastSyncReassigned is still set on login."); + Svc.Obs.add("weave:service:login:start", onLoginStart); + } + + await syncAndExpectNodeReassignment( + server, + "weave:service:sync:finish", + between, + "weave:service:sync:finish", + Service.storageURL + "rotary" + ); + + Svc.Obs.remove("weave:engine:reset-client:finish", observeReset); + Assert.equal(numResets, 1); + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); +}); + +// This test ends up being a failing fetch *after we're already logged in*. +add_task(async function test_momentary_401_info_collections() { + enableValidationPrefs(); + + _("Test a failure for info/collections that's resolved by reassignment."); + let server = await prepareServer(); + + _("First sync to prepare server contents."); + await Service.sync(); + + // Return a 401 for info requests, particularly info/collections. + let oldHandler = server.toplevelHandlers.info; + server.toplevelHandlers.info = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.info = oldHandler; + } + + await syncAndExpectNodeReassignment( + server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.infoURL + ); +}); + +add_task(async function test_momentary_401_storage_loggedin() { + enableValidationPrefs(); + + _( + "Test a failure for any storage URL, not just engine parts. " + + "Resolved by reassignment." + ); + let server = await prepareServer(); + + _("Performing initial sync to ensure we are logged in."); + await Service.sync(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + Assert.ok(Service.isLoggedIn, "already logged in"); + await syncAndExpectNodeReassignment( + server, + "weave:service:sync:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global" + ); +}); + +add_task(async function test_momentary_401_storage_loggedout() { + enableValidationPrefs(); + + _( + "Test a failure for any storage URL, not just engine parts. " + + "Resolved by reassignment." + ); + let server = await prepareServer(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + function undo() { + _("Undoing test changes."); + server.toplevelHandlers.storage = oldHandler; + } + + Assert.ok(!Service.isLoggedIn, "not already logged in"); + await syncAndExpectNodeReassignment( + server, + "weave:service:login:error", + undo, + "weave:service:sync:finish", + Service.storageURL + "meta/global" + ); +}); + +add_task(async function test_loop_avoidance_storage() { + enableValidationPrefs(); + + _( + "Test that a repeated failure doesn't result in a sync loop " + + "if node reassignment cannot resolve the failure." + ); + + let server = await prepareServer(); + + // Return a 401 for all storage requests. + let oldHandler = server.toplevelHandlers.storage; + server.toplevelHandlers.storage = handleReassign; + + let firstNotification = "weave:service:login:error"; + let secondNotification = "weave:service:login:error"; + let thirdNotification = "weave:service:sync:finish"; + + let deferred = Promise.withResolvers(); + + let getTokenCount = 0; + let mockTSC = { + // TokenServerClient + async getTokenUsingOAuth() { + getTokenCount++; + return { endpoint: server.baseURI + "1.1/johndoe/" }; + }, + }; + Service.identity._tokenServerClient = mockTSC; + + // Track the time. We want to make sure the duration between the first and + // second sync is small, and then that the duration between second and third + // is set to be large. + let now; + + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + Assert.equal(Service.clusterURL, ""); + + // We got a 401 mid-sync, and set the pref accordingly. + Assert.ok(Services.prefs.getBoolPref("services.sync.lastSyncReassigned")); + + // Update the timestamp. + now = Date.now(); + } + + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Svc.Obs.add(thirdNotification, onThirdSync); + + // This sync occurred within the backoff interval. + let elapsedTime = Date.now() - now; + Assert.less(elapsedTime, MINIMUM_BACKOFF_INTERVAL); + + // This pref will be true until a sync completes successfully. + Assert.ok(getReassigned()); + + // The timer will be set for some distant time. + // We store nextSync in prefs, which offers us only limited resolution. + // Include that logic here. + let expectedNextSync = + 1000 * Math.floor((now + MINIMUM_BACKOFF_INTERVAL) / 1000); + _("Next sync scheduled for " + Service.scheduler.nextSync); + _("Expected to be slightly greater than " + expectedNextSync); + + Assert.greaterOrEqual(Service.scheduler.nextSync, expectedNextSync); + Assert.ok(!!Service.scheduler.syncTimer); + + // Undo our evil scheme. + server.toplevelHandlers.storage = oldHandler; + + // Bring the timer forward to kick off a successful sync, so we can watch + // the pref get cleared. + Service.scheduler.scheduleNextSync(0); + } + function onThirdSync() { + Svc.Obs.remove(thirdNotification, onThirdSync); + + // That'll do for now; no more syncs. + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Third sync nextTick."); + Assert.ok(!getReassigned()); + Assert.equal(getTokenCount, 2); + Service.startOver().then(() => { + server.stop(deferred.resolve); + }); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + + now = Date.now(); + await Service.sync(); + await deferred.promise; +}); + +add_task(async function test_loop_avoidance_engine() { + enableValidationPrefs(); + + _( + "Test that a repeated 401 in an engine doesn't result in a sync loop " + + "if node reassignment cannot resolve the failure." + ); + let server = await prepareServer(); + let john = server.user("johndoe"); + + _("Enabling the Rotary engine."); + let { engine, syncID, tracker } = await registerRotaryEngine(); + let deferred = Promise.withResolvers(); + + let getTokenCount = 0; + let mockTSC = { + // TokenServerClient + async getTokenUsingOAuth() { + getTokenCount++; + return { endpoint: server.baseURI + "1.1/johndoe/" }; + }, + }; + Service.identity._tokenServerClient = mockTSC; + + // We need the server to be correctly set up prior to experimenting. Do this + // through a sync. + let global = { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + rotary: { version: engine.version, syncID }, + }; + john.createCollection("meta").insert("global", global); + + _("First sync to prepare server contents."); + await Service.sync(); + + _("Setting up Rotary collection to 401."); + let rotary = john.createCollection("rotary"); + let oldHandler = rotary.collectionHandler; + rotary.collectionHandler = handleReassign.bind(this, undefined); + + // Flag the Rotary engine to need syncing. + john.collection("rotary").timestamp += 1000; + + function onLoginStart() { + // lastSyncReassigned shouldn't be cleared until a sync has succeeded. + _("Ensuring that lastSyncReassigned is still set at next sync start."); + Assert.ok(getReassigned()); + } + + function beforeSuccessfulSync() { + _("Undoing test changes."); + rotary.collectionHandler = oldHandler; + } + + let firstNotification = "weave:service:sync:finish"; + let secondNotification = "weave:service:sync:finish"; + let thirdNotification = "weave:service:sync:finish"; + + // Track the time. We want to make sure the duration between the first and + // second sync is small, and then that the duration between second and third + // is set to be large. + let now; + + function onFirstSync() { + _("First sync completed."); + Svc.Obs.remove(firstNotification, onFirstSync); + Svc.Obs.add(secondNotification, onSecondSync); + + Assert.equal(Service.clusterURL, ""); + + _("Adding observer that lastSyncReassigned is still set on login."); + Svc.Obs.add("weave:service:login:start", onLoginStart); + + // We got a 401 mid-sync, and set the pref accordingly. + Assert.ok(Services.prefs.getBoolPref("services.sync.lastSyncReassigned")); + + // Update the timestamp. + now = Date.now(); + } + + function onSecondSync() { + _("Second sync completed."); + Svc.Obs.remove(secondNotification, onSecondSync); + Svc.Obs.add(thirdNotification, onThirdSync); + + // This sync occurred within the backoff interval. + let elapsedTime = Date.now() - now; + Assert.less(elapsedTime, MINIMUM_BACKOFF_INTERVAL); + + // This pref will be true until a sync completes successfully. + Assert.ok(getReassigned()); + + // The timer will be set for some distant time. + // We store nextSync in prefs, which offers us only limited resolution. + // Include that logic here. + let expectedNextSync = + 1000 * Math.floor((now + MINIMUM_BACKOFF_INTERVAL) / 1000); + _("Next sync scheduled for " + Service.scheduler.nextSync); + _("Expected to be slightly greater than " + expectedNextSync); + + Assert.greaterOrEqual(Service.scheduler.nextSync, expectedNextSync); + Assert.ok(!!Service.scheduler.syncTimer); + + // Undo our evil scheme. + beforeSuccessfulSync(); + + // Bring the timer forward to kick off a successful sync, so we can watch + // the pref get cleared. + Service.scheduler.scheduleNextSync(0); + } + + function onThirdSync() { + Svc.Obs.remove(thirdNotification, onThirdSync); + + // That'll do for now; no more syncs. + Service.scheduler.clearSyncTriggers(); + + // Make absolutely sure that any event listeners are done with their work + // before we proceed. + waitForZeroTimer(function () { + _("Third sync nextTick."); + Assert.ok(!getReassigned()); + Assert.equal(getTokenCount, 2); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + Service.startOver().then(() => { + server.stop(deferred.resolve); + }); + }); + } + + Svc.Obs.add(firstNotification, onFirstSync); + + now = Date.now(); + await Service.sync(); + await deferred.promise; + + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); +}); diff --git a/src/zen/tests/mochitests/sync/test_password_engine.js b/src/zen/tests/mochitests/sync/test_password_engine.js new file mode 100644 index 000000000..c9f278e33 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_password_engine.js @@ -0,0 +1,1258 @@ +const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); +const { LoginRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); + +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); + +const PropertyBag = Components.Constructor( + "@mozilla.org/hash-property-bag;1", + Ci.nsIWritablePropertyBag +); + +async function cleanup(engine, server) { + await engine._tracker.stop(); + await engine.wipeClient(); + engine.lastModified = null; + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + if (server) { + await promiseStopServer(server); + } +} + +add_task(async function setup() { + // Disable addon sync because AddonManager won't be initialized here. + await Service.engineManager.unregister("addons"); + await Service.engineManager.unregister("extension-storage"); +}); + +add_task(async function test_ignored_fields() { + _("Only changes to syncable fields should be tracked"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + enableValidationPrefs(); + + let loginInfo = new LoginInfo( + "https://example.com", + "", + null, + "username", + "password", + "", + "" + ); + + // Setting syncCounter to -1 so that it will be incremented to 0 when added. + loginInfo.syncCounter = -1; + let login = await Services.logins.addLoginAsync(loginInfo); + login.QueryInterface(Ci.nsILoginMetaInfo); // For `guid`. + + engine._tracker.start(); + + try { + let nonSyncableProps = new PropertyBag(); + nonSyncableProps.setProperty("timeLastUsed", Date.now()); + nonSyncableProps.setProperty("timesUsed", 3); + await Services.logins.modifyLoginAsync(login, nonSyncableProps); + + let noChanges = await engine.pullNewChanges(); + deepEqual(noChanges, {}, "Should not track non-syncable fields"); + + let syncableProps = new PropertyBag(); + syncableProps.setProperty("username", "newuser"); + await Services.logins.modifyLoginAsync(login, syncableProps); + + let changes = await engine.pullNewChanges(); + deepEqual( + Object.keys(changes), + [login.guid], + "Should track syncable fields" + ); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_ignored_sync_credentials() { + _("Sync credentials in login manager should be ignored"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + enableValidationPrefs(); + + engine._tracker.start(); + + try { + let login = await Services.logins.addLoginAsync( + new LoginInfo( + FXA_PWDMGR_HOST, + null, + FXA_PWDMGR_REALM, + "fxa-uid", + "creds", + "", + "" + ) + ); + + let noChanges = await engine.pullNewChanges(); + deepEqual(noChanges, {}, "Should not track new FxA credentials"); + + let props = new PropertyBag(); + props.setProperty("password", "newcreds"); + await Services.logins.modifyLoginAsync(login, props); + + noChanges = await engine.pullNewChanges(); + deepEqual(noChanges, {}, "Should not track changes to FxA credentials"); + + let foundLogins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 0); + equal(foundLogins[0].everSynced, false); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_password_engine() { + _("Basic password sync test"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("passwords"); + + enableValidationPrefs(); + + _("Add new login to upload during first sync"); + let newLogin; + { + let login = new LoginInfo( + "https://example.com", + "", + null, + "username", + "password", + "", + "" + ); + await Services.logins.addLoginAsync(login); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://example.com", + }); + equal(logins.length, 1, "Should find new login in login manager"); + newLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + + // Insert a server record that's older, so that we prefer the local one. + let rec = new LoginRec("passwords", newLogin.guid); + rec.formSubmitURL = newLogin.formActionOrigin; + rec.httpRealm = newLogin.httpRealm; + rec.hostname = newLogin.origin; + rec.username = newLogin.username; + rec.password = "sekrit"; + let remotePasswordChangeTime = Date.now() - 1 * 60 * 60 * 24 * 1000; + rec.timeCreated = remotePasswordChangeTime; + rec.timePasswordChanged = remotePasswordChangeTime; + collection.insert( + newLogin.guid, + encryptPayload(rec.cleartext), + remotePasswordChangeTime / 1000 + ); + } + + _("Add login with older password change time to replace during first sync"); + let oldLogin; + { + let login = new LoginInfo( + "https://mozilla.com", + "", + null, + "us3r", + "0ldpa55", + "", + "" + ); + await Services.logins.addLoginAsync(login); + + let props = new PropertyBag(); + let localPasswordChangeTime = Date.now() - 1 * 60 * 60 * 24 * 1000; + props.setProperty("timePasswordChanged", localPasswordChangeTime); + await Services.logins.modifyLoginAsync(login, props); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://mozilla.com", + }); + equal(logins.length, 1, "Should find old login in login manager"); + oldLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + equal(oldLogin.timePasswordChanged, localPasswordChangeTime); + + let rec = new LoginRec("passwords", oldLogin.guid); + rec.hostname = oldLogin.origin; + rec.formSubmitURL = oldLogin.formActionOrigin; + rec.httpRealm = oldLogin.httpRealm; + rec.username = oldLogin.username; + // Change the password and bump the password change time to ensure we prefer + // the remote one during reconciliation. + rec.password = "n3wpa55"; + rec.usernameField = oldLogin.usernameField; + rec.passwordField = oldLogin.usernameField; + rec.timeCreated = oldLogin.timeCreated; + rec.timePasswordChanged = Date.now(); + collection.insert(oldLogin.guid, encryptPayload(rec.cleartext)); + } + + await engine._tracker.stop(); + + try { + await sync_engine_and_validate_telem(engine, false); + + let newRec = collection.cleartext(newLogin.guid); + equal( + newRec.password, + "password", + "Should update remote password for newer login" + ); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://mozilla.com", + }); + equal( + logins[0].password, + "n3wpa55", + "Should update local password for older login" + ); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_outgoing() { + _("Test syncing outgoing records"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + let loginInfo = new LoginInfo( + "http://mozilla.com", + "http://mozilla.com", + null, + "theuser", + "thepassword", + "username", + "password" + ); + let login = await Services.logins.addLoginAsync(loginInfo); + + engine._tracker.start(); + + try { + let foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 1); + equal(foundLogins[0].everSynced, false); + equal(collection.count(), 0); + + let guid = foundLogins[0].QueryInterface(Ci.nsILoginMetaInfo).guid; + + let changes = await engine.getChangedIDs(); + let change = changes[guid]; + equal(Object.keys(changes).length, 1); + equal(change.counter, 1); + ok(!change.deleted); + + // This test modifies the password and then performs a sync and + // then ensures that the synced record is correct. This is done twice + // to ensure that syncing occurs correctly when the server record does not + // yet exist and when it does already exist. + for (let i = 1; i <= 2; i++) { + _("Modify the password iteration " + i); + foundLogins[0].password = "newpassword" + i; + await Services.logins.modifyLoginAsync(login, foundLogins[0]); + foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + equal(foundLogins.length, 1); + // On the first pass, the counter should be 2, one for the add and one for the modify. + // No sync has occurred yet so everSynced should be false. + // On the second pass, the counter will only be 1 for the modify. The everSynced + // property should be true as the sync happened on the last iteration. + equal(foundLogins[0].syncCounter, i == 2 ? 1 : 2); + equal(foundLogins[0].everSynced, i == 2); + + changes = await engine.getChangedIDs(); + change = changes[guid]; + equal(Object.keys(changes).length, 1); + equal(change.counter, i == 2 ? 1 : 2); + ok(!change.deleted); + + _("Perform sync after modifying the password"); + await sync_engine_and_validate_telem(engine, false); + + equal(Object.keys(await engine.getChangedIDs()), 0); + + // The remote login should have the updated password. + let newRec = collection.cleartext(guid); + equal( + newRec.password, + "newpassword" + i, + "Should update remote password for login" + ); + + foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 0); + equal(foundLogins[0].everSynced, true); + + login.password = "newpassword" + i; + } + + // Next, modify the username and sync. + _("Modify the username"); + foundLogins[0].username = "newuser"; + await Services.logins.modifyLoginAsync(login, foundLogins[0]); + foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 1); + equal(foundLogins[0].everSynced, true); + + _("Perform sync after modifying the username"); + await sync_engine_and_validate_telem(engine, false); + + // The remote login should have the updated password. + let newRec = collection.cleartext(guid); + equal( + newRec.username, + "newuser", + "Should update remote username for login" + ); + + foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 0); + equal(foundLogins[0].everSynced, true); + + // Finally, remove the login. The server record should be marked as deleted. + _("Remove the login"); + equal(collection.count(), 1); + equal(await Services.logins.countLoginsAsync("", "", ""), 2); + equal((await Services.logins.getAllLogins()).length, 2); + ok(await engine._store.itemExists(guid)); + + ok((await engine._store.getAllIDs())[guid]); + + await Services.logins.removeLoginAsync(foundLogins[0]); + foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + equal(foundLogins.length, 0); + + changes = await engine.getChangedIDs(); + change = changes[guid]; + equal(Object.keys(changes).length, 1); + equal(change.counter, 1); + ok(change.deleted); + + _("Perform sync after removing the login"); + await sync_engine_and_validate_telem(engine, false); + + equal(collection.count(), 1); + let payload = collection.payloads()[0]; + ok(payload.deleted); + + equal(Object.keys(await engine.getChangedIDs()), 0); + + // All of these should not include the deleted login. Only the FxA password should exist. + equal(await Services.logins.countLoginsAsync("", "", ""), 1); + equal((await Services.logins.getAllLogins()).length, 1); + ok(!(await engine._store.itemExists(guid))); + + // getAllIDs includes deleted items but skips the FxA login. + ok((await engine._store.getAllIDs())[guid]); + let deletedLogin = await engine._store._getLoginFromGUID(guid); + + equal(deletedLogin.hostname, null, "deleted login hostname"); + equal( + deletedLogin.formActionOrigin, + null, + "deleted login formActionOrigin" + ); + equal(deletedLogin.formSubmitURL, null, "deleted login formSubmitURL"); + equal(deletedLogin.httpRealm, null, "deleted login httpRealm"); + equal(deletedLogin.username, null, "deleted login username"); + equal(deletedLogin.password, null, "deleted login password"); + equal(deletedLogin.usernameField, "", "deleted login usernameField"); + equal(deletedLogin.passwordField, "", "deleted login passwordField"); + equal(deletedLogin.unknownFields, null, "deleted login unknownFields"); + equal(deletedLogin.timeCreated, 0, "deleted login timeCreated"); + equal(deletedLogin.timeLastUsed, 0, "deleted login timeLastUsed"); + equal(deletedLogin.timesUsed, 0, "deleted login timesUsed"); + + // These fields are not reset when the login is removed. + equal(deletedLogin.guid, guid, "deleted login guid"); + equal(deletedLogin.everSynced, true, "deleted login everSynced"); + equal(deletedLogin.syncCounter, 0, "deleted login syncCounter"); + Assert.greater( + deletedLogin.timePasswordChanged, + 0, + "deleted login timePasswordChanged" + ); + } finally { + await engine._tracker.stop(); + + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_incoming() { + _("Test syncing incoming records"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + const checkFields = [ + "formSubmitURL", + "hostname", + "httpRealm", + "username", + "password", + "usernameField", + "passwordField", + "timeCreated", + ]; + + let guid1 = Utils.makeGUID(); + let details = { + formSubmitURL: "https://www.example.com", + hostname: "https://www.example.com", + httpRealm: null, + username: "camel", + password: "llama", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + + try { + // This test creates a remote server record and then verifies that the login + // has been added locally after the sync occurs. + _("Create remote login"); + collection.insertRecord(Object.assign({}, details, { id: guid1 })); + + _("Perform sync when remote login has been added"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + equal(logins.length, 1); + + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); + checkFields.forEach(field => { + equal(logins[0][field], details[field]); + }); + equal(logins[0].timePasswordChanged, details.timePasswordChanged); + equal(logins[0].syncCounter, 0); + equal(logins[0].everSynced, true); + + // Modify the password within the remote record and then sync again. + _("Perform sync when remote login's password has been modified"); + let newTime = Date.now(); + collection.updateRecord( + guid1, + cleartext => { + cleartext.password = "alpaca"; + }, + newTime / 1000 + 10 + ); + + await engine.setLastSync(newTime / 1000 - 30); + await sync_engine_and_validate_telem(engine, false); + + logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + equal(logins.length, 1); + + details.password = "alpaca"; + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); + checkFields.forEach(field => { + equal(logins[0][field], details[field]); + }); + Assert.greater(logins[0].timePasswordChanged, details.timePasswordChanged); + equal(logins[0].syncCounter, 0); + equal(logins[0].everSynced, true); + + // Modify the username within the remote record and then sync again. + _("Perform sync when remote login's username has been modified"); + newTime = Date.now(); + collection.updateRecord( + guid1, + cleartext => { + cleartext.username = "guanaco"; + }, + newTime / 1000 + 10 + ); + + await engine.setLastSync(newTime / 1000 - 30); + await sync_engine_and_validate_telem(engine, false); + + logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + equal(logins.length, 1); + + details.username = "guanaco"; + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); + checkFields.forEach(field => { + equal(logins[0][field], details[field]); + }); + Assert.greater(logins[0].timePasswordChanged, details.timePasswordChanged); + equal(logins[0].syncCounter, 0); + equal(logins[0].everSynced, true); + + // Mark the remote record as deleted and then sync again. + _("Perform sync when remote login has been marked for deletion"); + newTime = Date.now(); + collection.updateRecord( + guid1, + cleartext => { + cleartext.deleted = true; + }, + newTime / 1000 + 10 + ); + + await engine.setLastSync(newTime / 1000 - 30); + await sync_engine_and_validate_telem(engine, false); + + logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + equal(logins.length, 0); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_incoming_deleted() { + _("Test syncing incoming deleted records"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + let guid1 = Utils.makeGUID(); + let details2 = { + formSubmitURL: "https://www.example.org", + hostname: "https://www.example.org", + httpRealm: null, + username: "capybara", + password: "beaver", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + deleted: true, + }; + + try { + // This test creates a remote server record that has been deleted + // and then verifies that the login is not imported locally. + _("Create remote login"); + collection.insertRecord(Object.assign({}, details2, { id: guid1 })); + + _("Perform sync when remote login has been deleted"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + equal(logins.length, 0); + ok(!(await engine._store.getAllIDs())[guid1]); + ok(!(await engine._store.itemExists(guid1))); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_incoming_deleted_localchanged_remotenewer() { + _( + "Test syncing incoming deleted records where the local login has been changed but the remote record is newer" + ); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + let loginInfo = new LoginInfo( + "http://mozilla.com", + "http://mozilla.com", + null, + "kangaroo", + "kaola", + "username", + "password" + ); + let login = await Services.logins.addLoginAsync(loginInfo); + let guid = login.QueryInterface(Ci.nsILoginMetaInfo).guid; + + try { + _("Perform sync on new login"); + await sync_engine_and_validate_telem(engine, false); + + let foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://mozilla.com", + }); + foundLogins[0].password = "wallaby"; + await Services.logins.modifyLoginAsync(login, foundLogins[0]); + + // Use a time in the future to ensure that the remote record is newer. + collection.updateRecord( + guid, + cleartext => { + cleartext.deleted = true; + }, + Date.now() / 1000 + 1000 + ); + + _( + "Perform sync when remote login has been deleted and local login has been changed" + ); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://mozilla.com", + }); + equal(logins.length, 0); + ok(await engine._store.getAllIDs()); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_incoming_deleted_localchanged_localnewer() { + _( + "Test syncing incoming deleted records where the local login has been changed but the local record is newer" + ); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + let loginInfo = new LoginInfo( + "http://www.mozilla.com", + "http://www.mozilla.com", + null, + "lion", + "tiger", + "username", + "password" + ); + let login = await Services.logins.addLoginAsync(loginInfo); + let guid = login.QueryInterface(Ci.nsILoginMetaInfo).guid; + + try { + _("Perform sync on new login"); + await sync_engine_and_validate_telem(engine, false); + + let foundLogins = await Services.logins.searchLoginsAsync({ + origin: "http://www.mozilla.com", + }); + foundLogins[0].password = "cheetah"; + await Services.logins.modifyLoginAsync(login, foundLogins[0]); + + // Use a time in the past to ensure that the local record is newer. + collection.updateRecord( + guid, + cleartext => { + cleartext.deleted = true; + }, + Date.now() / 1000 - 1000 + ); + + _( + "Perform sync when remote login has been deleted and local login has been changed" + ); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "http://www.mozilla.com", + }); + equal(logins.length, 1); + equal(logins[0].password, "cheetah"); + equal(logins[0].syncCounter, 0); + equal(logins[0].everSynced, true); + ok(await engine._store.getAllIDs()); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_incoming_no_formactionorigin() { + _("Test syncing incoming a record where there is no formActionOrigin"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + const checkFields = [ + "formSubmitURL", + "hostname", + "httpRealm", + "username", + "password", + "usernameField", + "passwordField", + "timeCreated", + ]; + + let guid1 = Utils.makeGUID(); + let details = { + formSubmitURL: "", + hostname: "https://www.example.com", + httpRealm: null, + username: "rabbit", + password: "squirrel", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + + try { + // This test creates a remote server record and then verifies that the login + // has been added locally after the sync occurs. + _("Create remote login"); + collection.insertRecord(Object.assign({}, details, { id: guid1 })); + + _("Perform sync when remote login has been added"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + formActionOrigin: "", + }); + equal(logins.length, 1); + + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); + checkFields.forEach(field => { + equal(logins[0][field], details[field]); + }); + equal(logins[0].timePasswordChanged, details.timePasswordChanged); + equal(logins[0].syncCounter, 0); + equal(logins[0].everSynced, true); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_password_dupe() { + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("passwords"); + + let guid1 = Utils.makeGUID(); + let rec1 = new LoginRec("passwords", guid1); + let guid2 = Utils.makeGUID(); + let cleartext = { + formSubmitURL: "https://www.example.com", + hostname: "https://www.example.com", + httpRealm: null, + username: "foo", + password: "bar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Math.round(Date.now()), + timePasswordChanged: Math.round(Date.now()), + }; + rec1.cleartext = cleartext; + + _("Create remote record with same details and guid1"); + collection.insert(guid1, encryptPayload(rec1.cleartext)); + + _("Create remote record with guid2"); + collection.insert(guid2, encryptPayload(cleartext)); + + _("Create local record with same details and guid1"); + await engine._store.create(rec1); + + try { + _("Perform sync"); + await sync_engine_and_validate_telem(engine, true); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + + equal(logins.length, 1); + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid2); + equal(null, collection.payload(guid1)); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_updated_null_password_sync() { + _("Ensure updated null login username is converted to a string"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("passwords"); + + let guid1 = Utils.makeGUID(); + let guid2 = Utils.makeGUID(); + let remoteDetails = { + formSubmitURL: "https://www.nullupdateexample.com", + hostname: "https://www.nullupdateexample.com", + httpRealm: null, + username: null, + password: "bar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + let localDetails = { + formSubmitURL: "https://www.nullupdateexample.com", + hostname: "https://www.nullupdateexample.com", + httpRealm: null, + username: "foo", + password: "foobar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + + _("Create remote record with same details and guid1"); + collection.insertRecord(Object.assign({}, remoteDetails, { id: guid1 })); + + try { + _("Create local updated login with null password"); + await engine._store.update(Object.assign({}, localDetails, { id: guid2 })); + + _("Perform sync"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.nullupdateexample.com", + }); + + equal(logins.length, 1); + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_updated_undefined_password_sync() { + _("Ensure updated undefined login username is converted to a string"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("passwords"); + + let guid1 = Utils.makeGUID(); + let guid2 = Utils.makeGUID(); + let remoteDetails = { + formSubmitURL: "https://www.undefinedupdateexample.com", + hostname: "https://www.undefinedupdateexample.com", + httpRealm: null, + username: undefined, + password: "bar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + let localDetails = { + formSubmitURL: "https://www.undefinedupdateexample.com", + hostname: "https://www.undefinedupdateexample.com", + httpRealm: null, + username: "foo", + password: "foobar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + + _("Create remote record with same details and guid1"); + collection.insertRecord(Object.assign({}, remoteDetails, { id: guid1 })); + + try { + _("Create local updated login with undefined password"); + await engine._store.update(Object.assign({}, localDetails, { id: guid2 })); + + _("Perform sync"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.undefinedupdateexample.com", + }); + + equal(logins.length, 1); + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_new_null_password_sync() { + _("Ensure new null login username is converted to a string"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let guid1 = Utils.makeGUID(); + let rec1 = new LoginRec("passwords", guid1); + rec1.cleartext = { + formSubmitURL: "https://www.example.com", + hostname: "https://www.example.com", + httpRealm: null, + username: null, + password: "bar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + + try { + _("Create local login with null password"); + await engine._store.create(rec1); + + _("Perform sync"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + + equal(logins.length, 1); + notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, null); + notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, undefined); + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, ""); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_new_undefined_password_sync() { + _("Ensure new undefined login username is converted to a string"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let guid1 = Utils.makeGUID(); + let rec1 = new LoginRec("passwords", guid1); + rec1.cleartext = { + formSubmitURL: "https://www.example.com", + hostname: "https://www.example.com", + httpRealm: null, + username: undefined, + password: "bar", + usernameField: "username-field", + passwordField: "password-field", + timeCreated: Date.now(), + timePasswordChanged: Date.now(), + }; + + try { + _("Create local login with undefined password"); + await engine._store.create(rec1); + + _("Perform sync"); + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://www.example.com", + }); + + equal(logins.length, 1); + notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, null); + notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, undefined); + equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, ""); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_sync_password_validation() { + // This test isn't in test_password_validator to avoid duplicating cleanup. + _("Ensure that if a password validation happens, it ends up in the ping"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + Svc.PrefBranch.setIntPref("engine.passwords.validation.interval", 0); + Svc.PrefBranch.setIntPref( + "engine.passwords.validation.percentageChance", + 100 + ); + Svc.PrefBranch.setIntPref("engine.passwords.validation.maxRecords", -1); + Svc.PrefBranch.setBoolPref("engine.passwords.validation.enabled", true); + + try { + let ping = await wait_for_ping(() => Service.sync()); + + let engineInfo = ping.engines.find(e => e.name == "passwords"); + ok(engineInfo, "Engine should be in ping"); + + let validation = engineInfo.validation; + ok(validation, "Engine should have validation info"); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_roundtrip_unknown_fields() { + _( + "Testing that unknown fields from other clients get roundtripped back to server" + ); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("passwords"); + + enableValidationPrefs(); + + _("Add login with older password change time to replace during first sync"); + let oldLogin; + { + let login = new LoginInfo( + "https://mozilla.com", + "", + null, + "us3r", + "0ldpa55", + "", + "" + ); + await Services.logins.addLoginAsync(login); + + let props = new PropertyBag(); + let localPasswordChangeTime = Math.round( + Date.now() - 1 * 60 * 60 * 24 * 1000 + ); + props.setProperty("timePasswordChanged", localPasswordChangeTime); + await Services.logins.modifyLoginAsync(login, props); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://mozilla.com", + }); + equal(logins.length, 1, "Should find old login in login manager"); + oldLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + equal(oldLogin.timePasswordChanged, localPasswordChangeTime); + + let rec = new LoginRec("passwords", oldLogin.guid); + rec.hostname = oldLogin.origin; + rec.formSubmitURL = oldLogin.formActionOrigin; + rec.httpRealm = oldLogin.httpRealm; + rec.username = oldLogin.username; + // Change the password and bump the password change time to ensure we prefer + // the remote one during reconciliation. + rec.password = "n3wpa55"; + rec.usernameField = oldLogin.usernameField; + rec.passwordField = oldLogin.usernameField; + rec.timeCreated = oldLogin.timeCreated; + rec.timePasswordChanged = Math.round(Date.now()); + + // pretend other clients have some snazzy new fields + // we don't quite understand yet + rec.cleartext.someStrField = "I am a str"; + rec.cleartext.someObjField = { newField: "I am a new field" }; + collection.insert(oldLogin.guid, encryptPayload(rec.cleartext)); + } + + await engine._tracker.stop(); + + try { + await sync_engine_and_validate_telem(engine, false); + + let logins = await Services.logins.searchLoginsAsync({ + origin: "https://mozilla.com", + }); + equal( + logins[0].password, + "n3wpa55", + "Should update local password for older login" + ); + let expectedUnknowns = JSON.stringify({ + someStrField: "I am a str", + someObjField: { newField: "I am a new field" }, + }); + // Check that the local record has all unknown fields properly + // stringified + equal(logins[0].unknownFields, expectedUnknowns); + + // Check that the server has the unknown fields unfurled and on the + // top-level record + let serverRec = collection.cleartext(oldLogin.guid); + equal(serverRec.someStrField, "I am a str"); + equal(serverRec.someObjField.newField, "I am a new field"); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_new_passwords_from_csv() { + _("Test syncing records imported from a csv file"); + + let engine = Service.engineManager.get("passwords"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("passwords"); + + engine._tracker.start(); + + let data = [ + { + hostname: "https://example.com", + url: "https://example.com/path", + username: "exampleuser", + password: "examplepassword", + }, + { + hostname: "https://mozilla.org", + url: "https://mozilla.org", + username: "mozillauser", + password: "mozillapassword", + }, + { + hostname: "https://www.example.org", + url: "https://www.example.org/example1/example2", + username: "person", + password: "mypassword", + }, + ]; + + let csvData = ["url,username,login_password"]; + for (let row of data) { + csvData.push(row.url + "," + row.username + "," + row.password); + } + + let csvFile = FileTestUtils.getTempFile(`firefox_logins.csv`); + await IOUtils.writeUTF8(csvFile.path, csvData.join("\r\n")); + + await LoginCSVImport.importFromCSV(csvFile.path); + + equal( + engine._tracker.score, + SCORE_INCREMENT_XLARGE, + "Should only get one update notification for import" + ); + + _("Ensure that the csv import is correct"); + for (let item of data) { + let foundLogins = await Services.logins.searchLoginsAsync({ + origin: item.hostname, + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 1); + equal(foundLogins[0].everSynced, false); + equal(foundLogins[0].username, item.username); + equal(foundLogins[0].password, item.password); + } + + _("Perform sync after modifying the password"); + await sync_engine_and_validate_telem(engine, false); + + _("Verify that the sync counter and status are updated"); + for (let item of data) { + let foundLogins = await Services.logins.searchLoginsAsync({ + origin: item.hostname, + }); + equal(foundLogins.length, 1); + equal(foundLogins[0].syncCounter, 0); + equal(foundLogins[0].everSynced, true); + equal(foundLogins[0].username, item.username); + equal(foundLogins[0].password, item.password); + item.guid = foundLogins[0].guid; + } + + equal(Object.keys(await engine.getChangedIDs()), 0); + equal(collection.count(), 3); + + for (let item of data) { + // The remote login should have the imported username and password. + let newRec = collection.cleartext(item.guid); + equal(newRec.username, item.username); + equal(newRec.password, item.password); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_password_store.js b/src/zen/tests/mochitests/sync/test_password_store.js new file mode 100644 index 000000000..3c5916245 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_password_store.js @@ -0,0 +1,399 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { LoginRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { SyncedRecordsTelemetry } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" +); + +async function checkRecord( + name, + record, + expectedCount, + timeCreated, + expectedTimeCreated, + timePasswordChanged, + expectedTimePasswordChanged, + recordIsUpdated +) { + let engine = Service.engineManager.get("passwords"); + let store = engine._store; + + let logins = await Services.logins.searchLoginsAsync({ + origin: record.hostname, + formActionOrigin: record.formSubmitURL, + }); + + _("Record" + name + ":" + JSON.stringify(logins)); + _("Count" + name + ":" + logins.length); + + Assert.equal(logins.length, expectedCount); + + if (expectedCount > 0) { + Assert.ok(!!(await store.getAllIDs())[record.id]); + let stored_record = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + + if (timeCreated !== undefined) { + Assert.equal(stored_record.timeCreated, expectedTimeCreated); + } + + if (timePasswordChanged !== undefined) { + if (recordIsUpdated) { + Assert.greaterOrEqual( + stored_record.timePasswordChanged, + expectedTimePasswordChanged + ); + } else { + Assert.equal( + stored_record.timePasswordChanged, + expectedTimePasswordChanged + ); + } + return stored_record.timePasswordChanged; + } + } else { + Assert.ok(!(await store.getAllIDs())[record.id]); + } + return undefined; +} + +async function changePassword( + name, + hostname, + password, + expectedCount, + timeCreated, + expectedTimeCreated, + timePasswordChanged, + expectedTimePasswordChanged, + insert, + recordIsUpdated +) { + const BOGUS_GUID = "zzzzzz" + hostname; + let record = new LoginRec("passwords", BOGUS_GUID); + record.cleartext = { + id: BOGUS_GUID, + hostname, + formSubmitURL: hostname, + username: "john", + password, + usernameField: "username", + passwordField: "password", + }; + + if (timeCreated !== undefined) { + record.timeCreated = timeCreated; + } + + if (timePasswordChanged !== undefined) { + record.timePasswordChanged = timePasswordChanged; + } + + let engine = Service.engineManager.get("passwords"); + let store = engine._store; + + if (insert) { + let countTelemetry = new SyncedRecordsTelemetry(); + Assert.equal( + (await store.applyIncomingBatch([record], countTelemetry)).length, + 0 + ); + } + + return checkRecord( + name, + record, + expectedCount, + timeCreated, + expectedTimeCreated, + timePasswordChanged, + expectedTimePasswordChanged, + recordIsUpdated + ); +} + +async function test_apply_records_with_times( + hostname, + timeCreated, + timePasswordChanged +) { + // The following record is going to be inserted in the store and it needs + // to be found there. Then its timestamps are going to be compared to + // the expected values. + await changePassword( + " ", + hostname, + "password", + 1, + timeCreated, + timeCreated, + timePasswordChanged, + timePasswordChanged, + true + ); +} + +async function test_apply_multiple_records_with_times() { + // The following records are going to be inserted in the store and they need + // to be found there. Then their timestamps are going to be compared to + // the expected values. + await changePassword( + "A", + "http://foo.a.com", + "password", + 1, + undefined, + undefined, + undefined, + undefined, + true + ); + await changePassword( + "B", + "http://foo.b.com", + "password", + 1, + 1000, + 1000, + undefined, + undefined, + true + ); + await changePassword( + "C", + "http://foo.c.com", + "password", + 1, + undefined, + undefined, + 1000, + 1000, + true + ); + await changePassword( + "D", + "http://foo.d.com", + "password", + 1, + 1000, + 1000, + 1000, + 1000, + true + ); + + // The following records are not going to be inserted in the store and they + // are not going to be found there. + await changePassword( + "NotInStoreA", + "http://foo.aaaa.com", + "password", + 0, + undefined, + undefined, + undefined, + undefined, + false + ); + await changePassword( + "NotInStoreB", + "http://foo.bbbb.com", + "password", + 0, + 1000, + 1000, + undefined, + undefined, + false + ); + await changePassword( + "NotInStoreC", + "http://foo.cccc.com", + "password", + 0, + undefined, + undefined, + 1000, + 1000, + false + ); + await changePassword( + "NotInStoreD", + "http://foo.dddd.com", + "password", + 0, + 1000, + 1000, + 1000, + 1000, + false + ); +} + +async function test_apply_same_record_with_different_times() { + // The following record is going to be inserted multiple times in the store + // and it needs to be found there. Then its timestamps are going to be + // compared to the expected values. + + /* eslint-disable no-unused-vars */ + /* The eslint linter thinks that timePasswordChanged is unused, even though + it is passed as an argument to changePassword. */ + var timePasswordChanged = 100; + timePasswordChanged = await changePassword( + "A", + "http://a.tn", + "password", + 1, + 100, + 100, + 100, + timePasswordChanged, + true + ); + timePasswordChanged = await changePassword( + "A", + "http://a.tn", + "password", + 1, + 100, + 100, + 800, + timePasswordChanged, + true, + true + ); + timePasswordChanged = await changePassword( + "A", + "http://a.tn", + "password", + 1, + 500, + 100, + 800, + timePasswordChanged, + true, + true + ); + timePasswordChanged = await changePassword( + "A", + "http://a.tn", + "password2", + 1, + 500, + 100, + 1536213005222, + timePasswordChanged, + true, + true + ); + timePasswordChanged = await changePassword( + "A", + "http://a.tn", + "password2", + 1, + 500, + 100, + 800, + timePasswordChanged, + true, + true + ); + /* eslint-enable no-unused-vars */ +} + +async function test_LoginRec_toString(store, recordData) { + let rec = await store.createRecord(recordData.id); + ok(rec); + ok(!rec.toString().includes(rec.password)); +} + +add_task(async function run_test() { + const BOGUS_GUID_A = "zzzzzzzzzzzz"; + const BOGUS_GUID_B = "yyyyyyyyyyyy"; + let recordA = new LoginRec("passwords", BOGUS_GUID_A); + let recordB = new LoginRec("passwords", BOGUS_GUID_B); + recordA.cleartext = { + id: BOGUS_GUID_A, + hostname: "http://foo.bar.com", + formSubmitURL: "http://foo.bar.com", + httpRealm: "secure", + username: "john", + password: "smith", + usernameField: "username", + passwordField: "password", + }; + recordB.cleartext = { + id: BOGUS_GUID_B, + hostname: "http://foo.baz.com", + formSubmitURL: "http://foo.baz.com", + username: "john", + password: "smith", + usernameField: "username", + passwordField: "password", + unknownStr: "an unknown string from another field", + }; + + let engine = Service.engineManager.get("passwords"); + let store = engine._store; + + try { + let countTelemetry = new SyncedRecordsTelemetry(); + Assert.equal( + (await store.applyIncomingBatch([recordA, recordB], countTelemetry)) + .length, + 0 + ); + + // Only the good record makes it to Services.logins. + let badLogins = await Services.logins.searchLoginsAsync({ + origin: recordA.hostname, + formActionOrigin: recordA.formSubmitURL, + httpRealm: recordA.httpRealm, + }); + let goodLogins = await Services.logins.searchLoginsAsync({ + origin: recordB.hostname, + formActionOrigin: recordB.formSubmitURL, + }); + + _("Bad: " + JSON.stringify(badLogins)); + _("Good: " + JSON.stringify(goodLogins)); + _("Count: " + badLogins.length + ", " + goodLogins.length); + + Assert.equal(goodLogins.length, 1); + Assert.equal(badLogins.length, 0); + + // applyIncoming should've put any unknown fields from the server + // into a catch-all unknownFields field + Assert.equal( + goodLogins[0].unknownFields, + JSON.stringify({ + unknownStr: "an unknown string from another field", + }) + ); + + Assert.ok(!!(await store.getAllIDs())[BOGUS_GUID_B]); + Assert.ok(!(await store.getAllIDs())[BOGUS_GUID_A]); + + await test_LoginRec_toString(store, recordB); + + await test_apply_records_with_times( + "http://afoo.baz.com", + undefined, + undefined + ); + await test_apply_records_with_times("http://bfoo.baz.com", 1000, undefined); + await test_apply_records_with_times("http://cfoo.baz.com", undefined, 2000); + await test_apply_records_with_times("http://dfoo.baz.com", 1000, 2000); + + await test_apply_multiple_records_with_times(); + + await test_apply_same_record_with_different_times(); + } finally { + await store.wipe(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_password_tracker.js b/src/zen/tests/mochitests/sync/test_password_tracker.js new file mode 100644 index 000000000..a613f933a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_password_tracker.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { PasswordEngine, LoginRec } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +let engine; +let store; +let tracker; + +add_task(async function setup() { + await Service.engineManager.register(PasswordEngine); + engine = Service.engineManager.get("passwords"); + store = engine._store; + tracker = engine._tracker; +}); + +add_task(async function test_tracking() { + let recordNum = 0; + + _("Verify we've got an empty tracker to work with."); + let changes = await engine.getChangedIDs(); + do_check_empty(changes); + + let exceptionHappened = false; + try { + await tracker.getChangedIDs(); + } catch (ex) { + exceptionHappened = true; + } + ok(exceptionHappened, "tracker does not keep track of changes"); + + async function createPassword() { + _("RECORD NUM: " + recordNum); + let record = new LoginRec("passwords", "GUID" + recordNum); + record.cleartext = { + id: "GUID" + recordNum, + hostname: "http://foo.bar.com", + formSubmitURL: "http://foo.bar.com", + username: "john" + recordNum, + password: "smith", + usernameField: "username", + passwordField: "password", + }; + recordNum++; + let login = store._nsLoginInfoFromRecord(record); + await Services.logins.addLoginAsync(login); + await tracker.asyncObserver.promiseObserversComplete(); + } + + try { + tracker.start(); + await createPassword(); + changes = await engine.getChangedIDs(); + do_check_attribute_count(changes, 1); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(changes.GUID0.counter, 1); + Assert.ok(typeof changes.GUID0.modified, "number"); + + _("Starting twice won't do any harm."); + tracker.start(); + await createPassword(); + changes = await engine.getChangedIDs(); + do_check_attribute_count(changes, 2); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + Assert.equal(changes.GUID0.counter, 1); + Assert.equal(changes.GUID1.counter, 1); + + // The tracker doesn't keep track of changes, so 3 changes + // should still be returned, but the score is not updated. + _("Let's stop tracking again."); + tracker.resetScore(); + await tracker.stop(); + await createPassword(); + changes = await engine.getChangedIDs(); + do_check_attribute_count(changes, 3); + Assert.equal(tracker.score, 0); + Assert.equal(changes.GUID0.counter, 1); + Assert.equal(changes.GUID1.counter, 1); + Assert.equal(changes.GUID2.counter, 1); + + _("Stopping twice won't do any harm."); + await tracker.stop(); + await createPassword(); + changes = await engine.getChangedIDs(); + do_check_attribute_count(changes, 4); + Assert.equal(tracker.score, 0); + } finally { + _("Clean up."); + await store.wipe(); + tracker.resetScore(); + await tracker.stop(); + } +}); + +add_task(async function test_onWipe() { + _("Verify we've got an empty tracker to work with."); + const changes = await engine.getChangedIDs(); + do_check_empty(changes); + Assert.equal(tracker.score, 0); + + try { + _("A store wipe should increment the score"); + tracker.start(); + await store.wipe(); + await tracker.asyncObserver.promiseObserversComplete(); + + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + } finally { + tracker.resetScore(); + await tracker.stop(); + } +}); + +add_task(async function test_removeAllLogins() { + let recordNum = 0; + _("Verify that all tracked logins are removed."); + + // Perform this test twice, the first time where a sync is not performed + // between adding and removing items and the second time where a sync is + // performed. In the former case, the logins will just be deleted because + // they have never been synced, so they won't be detected as changes. In + // the latter case, the logins have been synced so they will be marked for + // deletion. + for (let syncBeforeRemove of [false, true]) { + async function createPassword() { + _("RECORD NUM: " + recordNum); + let record = new LoginRec("passwords", "GUID" + recordNum); + record.cleartext = { + id: "GUID" + recordNum, + hostname: "http://foo.bar.com", + formSubmitURL: "http://foo.bar.com", + username: "john" + recordNum, + password: "smith", + usernameField: "username", + passwordField: "password", + }; + recordNum++; + let login = store._nsLoginInfoFromRecord(record); + await Services.logins.addLoginAsync(login); + + await tracker.asyncObserver.promiseObserversComplete(); + } + try { + _("Tell tracker to start tracking changes"); + tracker.start(); + await createPassword(); + await createPassword(); + let changes = await engine.getChangedIDs(); + do_check_attribute_count(changes, 2); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + + if (syncBeforeRemove) { + let logins = await Services.logins.getAllLogins(); + for (let login of logins) { + engine.markSynced(login.guid); + } + } + + _("Tell sync to remove all logins"); + await Services.logins.removeAllUserFacingLoginsAsync(); + await tracker.asyncObserver.promiseObserversComplete(); + changes = await engine.getChangedIDs(); + do_check_attribute_count(changes, syncBeforeRemove ? 2 : 0); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 5); + + let deletedGuids = await engine._store.getAllIDs(); + if (syncBeforeRemove) { + for (let guid in deletedGuids) { + let deletedLogin = await engine._store._getLoginFromGUID(guid); + + Assert.equal(deletedLogin.hostname, null, "deleted login hostname"); + Assert.equal( + deletedLogin.formActionOrigin, + null, + "deleted login formActionOrigin" + ); + Assert.equal( + deletedLogin.formSubmitURL, + null, + "deleted login formSubmitURL" + ); + Assert.equal(deletedLogin.httpRealm, null, "deleted login httpRealm"); + Assert.equal(deletedLogin.username, null, "deleted login username"); + Assert.equal(deletedLogin.password, null, "deleted login password"); + Assert.equal( + deletedLogin.usernameField, + "", + "deleted login usernameField" + ); + Assert.equal( + deletedLogin.passwordField, + "", + "deleted login passwordField" + ); + Assert.equal( + deletedLogin.unknownFields, + null, + "deleted login unknownFields" + ); + Assert.equal( + deletedLogin.timeCreated, + 0, + "deleted login timeCreated" + ); + Assert.equal( + deletedLogin.timeLastUsed, + 0, + "deleted login timeLastUsed" + ); + Assert.equal(deletedLogin.timesUsed, 0, "deleted login timesUsed"); + + // These fields are not reset when the login is removed. + Assert.ok(deletedLogin.guid.startsWith("GUID"), "deleted login guid"); + Assert.equal( + deletedLogin.everSynced, + true, + "deleted login everSynced" + ); + Assert.equal( + deletedLogin.syncCounter, + 2, + "deleted login syncCounter" + ); + Assert.greater( + deletedLogin.timePasswordChanged, + 0, + "deleted login timePasswordChanged" + ); + } + } else { + Assert.equal( + Object.keys(deletedGuids).length, + 0, + "no logins remain after removeAllUserFacingLogins" + ); + } + } finally { + _("Clean up."); + await store.wipe(); + tracker.resetScore(); + await tracker.stop(); + } + } +}); diff --git a/src/zen/tests/mochitests/sync/test_password_validator.js b/src/zen/tests/mochitests/sync/test_password_validator.js new file mode 100644 index 000000000..e0e88fd23 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_password_validator.js @@ -0,0 +1,237 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { PasswordValidator } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { BridgedEngine } = ChromeUtils.importESModule( + "resource://services-sync/bridged_engine.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function getDummyServerAndClient() { + return { + server: [ + { + id: "11111", + guid: "11111", + hostname: "https://www.11111.com", + formSubmitURL: "https://www.11111.com", + password: "qwerty123", + passwordField: "pass", + username: "foobar", + usernameField: "user", + httpRealm: null, + }, + { + id: "22222", + guid: "22222", + hostname: "https://www.22222.org", + formSubmitURL: "https://www.22222.org", + password: "hunter2", + passwordField: "passwd", + username: "baz12345", + usernameField: "user", + httpRealm: null, + }, + { + id: "33333", + guid: "33333", + hostname: "https://www.33333.com", + formSubmitURL: "https://www.33333.com", + password: "p4ssw0rd", + passwordField: "passwad", + username: "quux", + usernameField: "user", + httpRealm: null, + }, + ], + client: [ + { + id: "11111", + guid: "11111", + hostname: "https://www.11111.com", + formSubmitURL: "https://www.11111.com", + password: "qwerty123", + passwordField: "pass", + username: "foobar", + usernameField: "user", + httpRealm: null, + }, + { + id: "22222", + guid: "22222", + hostname: "https://www.22222.org", + formSubmitURL: "https://www.22222.org", + password: "hunter2", + passwordField: "passwd", + username: "baz12345", + usernameField: "user", + httpRealm: null, + }, + { + id: "33333", + guid: "33333", + hostname: "https://www.33333.com", + formSubmitURL: "https://www.33333.com", + password: "p4ssw0rd", + passwordField: "passwad", + username: "quux", + usernameField: "user", + httpRealm: null, + }, + ], + }; +} + +add_task(async function test_valid() { + let { server, client } = getDummyServerAndClient(); + let validator = new PasswordValidator(); + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + equal(clientRecords.length, 3); + equal(records.length, 3); + equal(deletedRecords.length, 0); + deepEqual(problemData, validator.emptyProblemData()); +}); + +add_task(async function test_missing() { + let validator = new PasswordValidator(); + { + let { server, client } = getDummyServerAndClient(); + + client.pop(); + + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 2); + equal(records.length, 3); + equal(deletedRecords.length, 0); + + let expected = validator.emptyProblemData(); + expected.clientMissing.push("33333"); + deepEqual(problemData, expected); + } + { + let { server, client } = getDummyServerAndClient(); + + server.pop(); + + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 3); + equal(records.length, 2); + equal(deletedRecords.length, 0); + + let expected = validator.emptyProblemData(); + expected.serverMissing.push("33333"); + deepEqual(problemData, expected); + } +}); + +add_task(async function test_deleted() { + let { server, client } = getDummyServerAndClient(); + let deletionRecord = { id: "444444", guid: "444444", deleted: true }; + + server.push(deletionRecord); + let validator = new PasswordValidator(); + + let { problemData, clientRecords, records, deletedRecords } = + await validator.compareClientWithServer(client, server); + + equal(clientRecords.length, 3); + equal(records.length, 4); + deepEqual(deletedRecords, [deletionRecord]); + + let expected = validator.emptyProblemData(); + deepEqual(problemData, expected); +}); + +add_task(async function test_duplicates() { + let validator = new PasswordValidator(); + { + let { server, client } = getDummyServerAndClient(); + client.push(Cu.cloneInto(client[0], {})); + + let { problemData } = await validator.compareClientWithServer( + client, + server + ); + + let expected = validator.emptyProblemData(); + expected.clientDuplicates.push("11111"); + deepEqual(problemData, expected); + } + { + let { server, client } = getDummyServerAndClient(); + server.push(Cu.cloneInto(server[server.length - 1], {})); + + let { problemData } = await validator.compareClientWithServer( + client, + server + ); + + let expected = validator.emptyProblemData(); + expected.duplicates.push("33333"); + deepEqual(problemData, expected); + } +}); + +// A bridged engine (eg, the Rust logins engine) decrypts records with +// RawCryptoWrapper, so their cleartext is the raw payload string rather than a +// parsed object. `getServerItems` must still yield objects to the validator, +// otherwise every server record looks like it has no id and every local login +// is wrongly reported as `serverMissing`. +add_task(async function test_bridged_engine_server_items() { + let engine = new BridgedEngine("Passwords", Service); + engine._bridge = { + storageVersion: 1, + resetSyncId() { + return "syncIDAAAAAA"; + }, + }; + engine.enabled = true; + + let server = await serverForFoo(engine); + try { + await SyncTestingInfrastructure(server); + + let guid = "{1e110b98-1407-4801-baf1-f6ca91e0f982}"; + let login = { + id: guid, + hostname: "https://accounts.google.com", + formSubmitURL: "https://accounts.google.com", + username: "iosmztest", + password: "test15mz", + usernameField: "Email", + passwordField: "Passwd", + }; + server + .user("foo") + .collection("passwords") + .insert(guid, encryptPayload(login), new_timestamp()); + + let validator = new PasswordValidator(); + let serverItems = await validator.getServerItems(engine); + let clientItems = [{ ...login, guid, httpRealm: null }]; + + let { problemData } = await validator.compareClientWithServer( + clientItems, + serverItems + ); + + equal( + problemData.serverMissing.length, + 0, + "A record present on the server must not be reported as serverMissing" + ); + deepEqual(problemData, validator.emptyProblemData()); + } finally { + await promiseStopServer(server); + await engine.finalize(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_postqueue.js b/src/zen/tests/mochitests/sync/test_postqueue.js new file mode 100644 index 000000000..2e687bce1 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_postqueue.js @@ -0,0 +1,985 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { PostQueue } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); + +function makeRecord(nbytes) { + return { + toJSON: () => ({ payload: "x".repeat(nbytes) }), + }; +} + +// Note: This is 14 bytes. Tests make assumptions about this (even if it's just +// in setting config.max_request_bytes to a specific value). +makeRecord.nonPayloadOverhead = JSON.stringify(makeRecord(0).toJSON()).length; + +// Gives how many encoded bytes a request with the given payload +// sizes will be (assuming the records were created by makeRecord) +// requestBytesFor([20]) => 22, requestBytesFor([20, 20]) => 43 +function requestBytesFor(recordPayloadByteCounts) { + let requestBytes = 1; + for (let size of recordPayloadByteCounts) { + requestBytes += size + 1 + makeRecord.nonPayloadOverhead; + } + return requestBytes; +} + +function makePostQueue(config, lastModTime, responseGenerator) { + let stats = { + posts: [], + batches: [], + }; + let poster = (data, headers, batch, commit) => { + let payloadBytes = 0; + let numRecords = 0; + for (let record of JSON.parse(data)) { + if (config.max_record_payload_bytes) { + less( + record.payload.length, + config.max_record_payload_bytes, + "PostQueue should respect max_record_payload_bytes" + ); + } + payloadBytes += record.payload.length; + ++numRecords; + } + + let thisPost = { + nbytes: data.length, + batch, + commit, + payloadBytes, + numRecords, + }; + + if (headers.length) { + thisPost.headers = headers; + } + + // check that we respected the provided limits for the post + if (config.max_post_records) { + lessOrEqual( + numRecords, + config.max_post_records, + "PostQueue should respect max_post_records" + ); + } + + if (config.max_post_bytes) { + less( + payloadBytes, + config.max_post_bytes, + "PostQueue should respect max_post_bytes" + ); + } + + if (config.max_request_bytes) { + less( + thisPost.nbytes, + config.max_request_bytes, + "PostQueue should respect max_request_bytes" + ); + } + + stats.posts.push(thisPost); + + // Call this now so we can check if there's a batch id in it. + // Kind of cludgey, but allows us to have the correct batch id even + // before the next post is made. + let nextResponse = responseGenerator.next().value; + + // Record info for the batch. + + let curBatch = stats.batches[stats.batches.length - 1]; + // If there's no batch, it committed, or we requested a new one, + // then we need to start a new one. + if (!curBatch || batch == "true" || curBatch.didCommit) { + curBatch = { + posts: 0, + payloadBytes: 0, + numRecords: 0, + didCommit: false, + batch, + serverBatch: false, + }; + if (nextResponse.obj && nextResponse.obj.batch) { + curBatch.batch = nextResponse.obj.batch; + curBatch.serverBatch = true; + } + stats.batches.push(curBatch); + } + + // If we provided a batch id, it must be the same as the current batch + if (batch && batch != "true") { + equal(curBatch.batch, batch); + } + + curBatch.posts += 1; + curBatch.payloadBytes += payloadBytes; + curBatch.numRecords += numRecords; + curBatch.didCommit = commit; + + // if this is an actual server batch (or it's a one-shot batch), check that + // we respected the provided total limits + if (commit && (batch == "true" || curBatch.serverBatch)) { + if (config.max_total_records) { + lessOrEqual( + curBatch.numRecords, + config.max_total_records, + "PostQueue should respect max_total_records" + ); + } + + if (config.max_total_bytes) { + less( + curBatch.payloadBytes, + config.max_total_bytes, + "PostQueue should respect max_total_bytes" + ); + } + } + + return Promise.resolve(nextResponse); + }; + + let done = () => {}; + let pq = new PostQueue(poster, lastModTime, config, getTestLogger(), done); + return { pq, stats }; +} + +add_task(async function test_simple() { + let config = { + max_request_bytes: 1000, + max_record_payload_bytes: 1000, + }; + + const time = 11111111; + + function* responseGenerator() { + yield { + success: true, + status: 200, + headers: { + "x-weave-timestamp": time + 100, + "x-last-modified": time + 100, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + await pq.enqueue(makeRecord(10)); + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([10]), + payloadBytes: 10, + numRecords: 1, + commit: true, // we don't know if we have batch semantics, so committed. + headers: [["x-if-unmodified-since", time]], + batch: "true", + }, + ]); + deepEqual(stats.batches, [ + { + posts: 1, + payloadBytes: 10, + numRecords: 1, + didCommit: true, + batch: "true", + serverBatch: false, + }, + ]); +}); + +// Test we do the right thing when we need to make multiple posts when there +// are no batch semantics +add_task(async function test_max_request_bytes_no_batch() { + let config = { + max_request_bytes: 50, + max_record_payload_bytes: 50, + }; + + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 200, + headers: { + "x-weave-timestamp": time + 100, + "x-last-modified": time + 100, + }, + }; + yield { + success: true, + status: 200, + headers: { + "x-weave-timestamp": time + 200, + "x-last-modified": time + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + let payloadSize = 20 - makeRecord.nonPayloadOverhead; + await pq.enqueue(makeRecord(payloadSize)); // total size now 22 bytes - "[" + record + "]" + await pq.enqueue(makeRecord(payloadSize)); // total size now 43 bytes - "[" + record + "," + record + "]" + await pq.enqueue(makeRecord(payloadSize)); // this will exceed our byte limit, so will be in the 2nd POST. + await pq.flush(true); + deepEqual(stats.posts, [ + { + nbytes: 43, // 43 for the first part + payloadBytes: payloadSize * 2, + numRecords: 2, + commit: false, + headers: [["x-if-unmodified-since", time]], + batch: "true", + }, + { + nbytes: 22, + payloadBytes: payloadSize, + numRecords: 1, + commit: false, // we know we aren't in a batch, so never commit. + headers: [["x-if-unmodified-since", time + 100]], + batch: null, + }, + ]); + equal(stats.batches.filter(x => x.didCommit).length, 0); + equal(pq.lastModified, time + 200); +}); + +add_task(async function test_max_record_payload_bytes_no_batch() { + let config = { + max_request_bytes: 100, + max_record_payload_bytes: 50, + }; + + const time = 11111111; + + function* responseGenerator() { + yield { + success: true, + status: 200, + headers: { + "x-weave-timestamp": time + 100, + "x-last-modified": time + 100, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + // Should trigger when the record really is too large to fit + let { enqueued } = await pq.enqueue(makeRecord(51)); + ok(!enqueued); + // Shouldn't trigger when the encoded record is too big + ok( + (await pq.enqueue(makeRecord(50 - makeRecord.nonPayloadOverhead))).enqueued + ); // total size now 52 bytes - "[" + record + "]" + ok( + (await pq.enqueue(makeRecord(46 - makeRecord.nonPayloadOverhead))).enqueued + ); // total size now 99 bytes - "[" + record0 + "," + record1 + "]" + + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: 99, + payloadBytes: 50 + 46 - makeRecord.nonPayloadOverhead * 2, + numRecords: 2, + commit: true, // we know we aren't in a batch, so never commit. + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 1, + payloadBytes: 50 + 46 - makeRecord.nonPayloadOverhead * 2, + numRecords: 2, + didCommit: true, + batch: "true", + serverBatch: false, + }, + ]); + + equal(pq.lastModified, time + 100); +}); + +// Batch tests. + +// Test making a single post when batch semantics are in place. + +add_task(async function test_single_batch() { + let config = { + max_post_bytes: 1000, + max_post_records: 100, + max_total_records: 200, + max_record_payload_bytes: 1000, + }; + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time, "x-weave-timestamp": time + 100 }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok((await pq.enqueue(makeRecord(10))).enqueued); + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([10]), + numRecords: 1, + payloadBytes: 10, + commit: true, // we don't know if we have batch semantics, so committed. + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 1, + payloadBytes: 10, + numRecords: 1, + didCommit: true, + batch: 1234, + serverBatch: true, + }, + ]); +}); + +// Test we do the right thing when we need to make multiple posts due to +// max_post_bytes when there are batch semantics in place. +add_task(async function test_max_post_bytes_batch() { + let config = { + max_post_bytes: 50, + max_post_records: 4, + max_total_bytes: 5000, + max_total_records: 100, + max_record_payload_bytes: 50, + max_request_bytes: 4000, + }; + + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time, "x-weave-timestamp": time + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { + "x-last-modified": time + 200, + "x-weave-timestamp": time + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok((await pq.enqueue(makeRecord(20))).enqueued); // 20 + ok((await pq.enqueue(makeRecord(20))).enqueued); // 40 + // 60 would overflow, so post + ok((await pq.enqueue(makeRecord(20))).enqueued); // 20 + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([20, 20]), + payloadBytes: 40, + numRecords: 2, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + { + nbytes: requestBytesFor([20]), + payloadBytes: 20, + numRecords: 1, + commit: true, + batch: 1234, + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 2, + payloadBytes: 60, + numRecords: 3, + didCommit: true, + batch: 1234, + serverBatch: true, + }, + ]); + + equal(pq.lastModified, time + 200); +}); + +// Test we do the right thing when we need to make multiple posts due to +// max_request_bytes when there are batch semantics in place. +add_task(async function test_max_request_bytes_batch() { + let config = { + max_post_bytes: 60, + max_post_records: 40, + max_total_bytes: 5000, + max_total_records: 100, + max_record_payload_bytes: 500, + max_request_bytes: 100, + }; + + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time, "x-weave-timestamp": time + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { + "x-last-modified": time + 200, + "x-weave-timestamp": time + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok((await pq.enqueue(makeRecord(10))).enqueued); // post: 10, request: 26 (10 + 14 + 2) + ok((await pq.enqueue(makeRecord(10))).enqueued); // post: 20, request: 51 (10 + 14 + 1) * 2 + 1 + ok((await pq.enqueue(makeRecord(10))).enqueued); // post: 30, request: 76 (10 + 14 + 1) * 3 + 1 + // 1 more would be post: 40 (fine), request: 101, So we should post. + ok((await pq.enqueue(makeRecord(10))).enqueued); + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([10, 10, 10]), + payloadBytes: 30, + numRecords: 3, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + { + nbytes: requestBytesFor([10]), + payloadBytes: 10, + numRecords: 1, + commit: true, + batch: 1234, + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 2, + payloadBytes: 40, + numRecords: 4, + didCommit: true, + batch: 1234, + serverBatch: true, + }, + ]); + + equal(pq.lastModified, time + 200); +}); + +// Test we do the right thing when the batch bytes limit is exceeded. +add_task(async function test_max_total_bytes_batch() { + let config = { + max_post_bytes: 50, + max_post_records: 20, + max_total_bytes: 70, + max_total_records: 100, + max_record_payload_bytes: 50, + max_request_bytes: 500, + }; + + const time0 = 11111111; + const time1 = 22222222; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time0, "x-weave-timestamp": time0 + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time1, "x-weave-timestamp": time1 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 5678 }, + headers: { "x-last-modified": time1, "x-weave-timestamp": time1 + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 5678 }, + headers: { + "x-last-modified": time1 + 200, + "x-weave-timestamp": time1 + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time0, responseGenerator()); + + ok((await pq.enqueue(makeRecord(20))).enqueued); // payloads = post: 20, batch: 20 + ok((await pq.enqueue(makeRecord(20))).enqueued); // payloads = post: 40, batch: 40 + + // this will exceed our POST byte limit, so will be in the 2nd POST - but still in the first batch. + ok((await pq.enqueue(makeRecord(20))).enqueued); // payloads = post: 20, batch: 60 + + // this will exceed our batch byte limit, so will be in a new batch. + ok((await pq.enqueue(makeRecord(20))).enqueued); // payloads = post: 20, batch: 20 + ok((await pq.enqueue(makeRecord(20))).enqueued); // payloads = post: 40, batch: 40 + // This will exceed POST byte limit, so will be in the 4th post, part of the 2nd batch. + ok((await pq.enqueue(makeRecord(20))).enqueued); // payloads = post: 20, batch: 60 + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([20, 20]), + payloadBytes: 40, + numRecords: 2, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time0]], + }, + { + nbytes: requestBytesFor([20]), + payloadBytes: 20, + numRecords: 1, + commit: true, + batch: 1234, + headers: [["x-if-unmodified-since", time0]], + }, + { + nbytes: requestBytesFor([20, 20]), + payloadBytes: 40, + numRecords: 2, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time1]], + }, + { + nbytes: requestBytesFor([20]), + payloadBytes: 20, + numRecords: 1, + commit: true, + batch: 5678, + headers: [["x-if-unmodified-since", time1]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 2, + payloadBytes: 60, + numRecords: 3, + didCommit: true, + batch: 1234, + serverBatch: true, + }, + { + posts: 2, + payloadBytes: 60, + numRecords: 3, + didCommit: true, + batch: 5678, + serverBatch: true, + }, + ]); + + equal(pq.lastModified, time1 + 200); +}); + +// Test we split up the posts when we exceed the record limit when batch semantics +// are in place. +add_task(async function test_max_post_records_batch() { + let config = { + max_post_bytes: 1000, + max_post_records: 2, + max_total_bytes: 5000, + max_total_records: 100, + max_record_payload_bytes: 1000, + max_request_bytes: 1000, + }; + + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time, "x-weave-timestamp": time + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { + "x-last-modified": time + 200, + "x-weave-timestamp": time + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok((await pq.enqueue(makeRecord(20))).enqueued); + ok((await pq.enqueue(makeRecord(20))).enqueued); + + // will exceed record limit of 2, so will be in 2nd post. + ok((await pq.enqueue(makeRecord(20))).enqueued); + + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([20, 20]), + numRecords: 2, + payloadBytes: 40, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + { + nbytes: requestBytesFor([20]), + numRecords: 1, + payloadBytes: 20, + commit: true, + batch: 1234, + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 2, + payloadBytes: 60, + numRecords: 3, + batch: 1234, + serverBatch: true, + didCommit: true, + }, + ]); + + equal(pq.lastModified, time + 200); +}); + +// Test we do the right thing when the batch record limit is exceeded. +add_task(async function test_max_records_batch() { + let config = { + max_post_bytes: 1000, + max_post_records: 3, + max_total_bytes: 10000, + max_total_records: 5, + max_record_payload_bytes: 1000, + max_request_bytes: 10000, + }; + + const time0 = 11111111; + const time1 = 22222222; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time0, "x-weave-timestamp": time0 + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time1, "x-weave-timestamp": time1 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 5678 }, + headers: { "x-last-modified": time1, "x-weave-timestamp": time1 + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 5678 }, + headers: { + "x-last-modified": time1 + 200, + "x-weave-timestamp": time1 + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time0, responseGenerator()); + + ok((await pq.enqueue(makeRecord(20))).enqueued); + ok((await pq.enqueue(makeRecord(20))).enqueued); + ok((await pq.enqueue(makeRecord(20))).enqueued); + + ok((await pq.enqueue(makeRecord(20))).enqueued); + ok((await pq.enqueue(makeRecord(20))).enqueued); + + ok((await pq.enqueue(makeRecord(20))).enqueued); + ok((await pq.enqueue(makeRecord(20))).enqueued); + ok((await pq.enqueue(makeRecord(20))).enqueued); + + ok((await pq.enqueue(makeRecord(20))).enqueued); + + await pq.flush(true); + + deepEqual(stats.posts, [ + { + // 3 records + nbytes: requestBytesFor([20, 20, 20]), + payloadBytes: 60, + numRecords: 3, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time0]], + }, + { + // 2 records -- end batch1 + nbytes: requestBytesFor([20, 20]), + payloadBytes: 40, + numRecords: 2, + commit: true, + batch: 1234, + headers: [["x-if-unmodified-since", time0]], + }, + { + // 3 records + nbytes: requestBytesFor([20, 20, 20]), + payloadBytes: 60, + numRecords: 3, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time1]], + }, + { + // 1 record -- end batch2 + nbytes: requestBytesFor([20]), + payloadBytes: 20, + numRecords: 1, + commit: true, + batch: 5678, + headers: [["x-if-unmodified-since", time1]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 2, + payloadBytes: 100, + numRecords: 5, + batch: 1234, + serverBatch: true, + didCommit: true, + }, + { + posts: 2, + payloadBytes: 80, + numRecords: 4, + batch: 5678, + serverBatch: true, + didCommit: true, + }, + ]); + + equal(pq.lastModified, time1 + 200); +}); + +// Test we do the right thing when the limits are met but not exceeded. +add_task(async function test_packed_batch() { + let config = { + max_post_bytes: 41, + max_post_records: 4, + + max_total_bytes: 81, + max_total_records: 8, + + max_record_payload_bytes: 20 + makeRecord.nonPayloadOverhead + 1, + max_request_bytes: requestBytesFor([10, 10, 10, 10]) + 1, + }; + + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { "x-last-modified": time, "x-weave-timestamp": time + 100 }, + }; + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { + "x-last-modified": time + 200, + "x-weave-timestamp": time + 200, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + ok((await pq.enqueue(makeRecord(10))).enqueued); + ok((await pq.enqueue(makeRecord(10))).enqueued); + ok((await pq.enqueue(makeRecord(10))).enqueued); + ok((await pq.enqueue(makeRecord(10))).enqueued); + + ok((await pq.enqueue(makeRecord(10))).enqueued); + ok((await pq.enqueue(makeRecord(10))).enqueued); + ok((await pq.enqueue(makeRecord(10))).enqueued); + ok((await pq.enqueue(makeRecord(10))).enqueued); + + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([10, 10, 10, 10]), + numRecords: 4, + payloadBytes: 40, + commit: false, + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + { + nbytes: requestBytesFor([10, 10, 10, 10]), + numRecords: 4, + payloadBytes: 40, + commit: true, + batch: 1234, + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 2, + payloadBytes: 80, + numRecords: 8, + batch: 1234, + serverBatch: true, + didCommit: true, + }, + ]); + + equal(pq.lastModified, time + 200); +}); + +// Tests that check that a single record fails to enqueue for the provided config +async function test_enqueue_failure_case(failureLimit, config) { + const time = 11111111; + function* responseGenerator() { + yield { + success: true, + status: 202, + obj: { batch: 1234 }, + headers: { + "x-last-modified": time + 100, + "x-weave-timestamp": time + 100, + }, + }; + } + + let { pq, stats } = makePostQueue(config, time, responseGenerator()); + // Check on empty postqueue + let result = await pq.enqueue(makeRecord(failureLimit + 1)); + ok(!result.enqueued); + notEqual(result.error, undefined); + + ok((await pq.enqueue(makeRecord(5))).enqueued); + + // check on nonempty postqueue + result = await pq.enqueue(makeRecord(failureLimit + 1)); + ok(!result.enqueued); + notEqual(result.error, undefined); + + // make sure that we keep working, skipping the bad record entirely + // (handling the error the queue reported is left up to caller) + ok((await pq.enqueue(makeRecord(5))).enqueued); + + await pq.flush(true); + + deepEqual(stats.posts, [ + { + nbytes: requestBytesFor([5, 5]), + numRecords: 2, + payloadBytes: 10, + commit: true, + batch: "true", + headers: [["x-if-unmodified-since", time]], + }, + ]); + + deepEqual(stats.batches, [ + { + posts: 1, + payloadBytes: 10, + numRecords: 2, + batch: 1234, + serverBatch: true, + didCommit: true, + }, + ]); + + equal(pq.lastModified, time + 100); +} + +add_task(async function test_max_post_bytes_enqueue_failure() { + await test_enqueue_failure_case(50, { + max_post_bytes: 50, + max_post_records: 100, + + max_total_bytes: 5000, + max_total_records: 100, + + max_record_payload_bytes: 500, + max_request_bytes: 500, + }); +}); + +add_task(async function test_max_request_bytes_enqueue_failure() { + await test_enqueue_failure_case(50, { + max_post_bytes: 500, + max_post_records: 100, + + max_total_bytes: 5000, + max_total_records: 100, + + max_record_payload_bytes: 500, + max_request_bytes: 50, + }); +}); + +add_task(async function test_max_record_payload_bytes_enqueue_failure() { + await test_enqueue_failure_case(50, { + max_post_bytes: 500, + max_post_records: 100, + + max_total_bytes: 5000, + max_total_records: 100, + + max_record_payload_bytes: 50, + max_request_bytes: 500, + }); +}); diff --git a/src/zen/tests/mochitests/sync/test_prefs_engine.js b/src/zen/tests/mochitests/sync/test_prefs_engine.js new file mode 100644 index 000000000..77a4474cb --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_prefs_engine.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { getPrefsGUIDForTest } = ChromeUtils.importESModule( + "resource://services-sync/engines/prefs.sys.mjs" +); +const PREFS_GUID = getPrefsGUIDForTest(); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +async function cleanup(engine, server) { + await engine._tracker.stop(); + await engine.wipeClient(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Service.recordManager.clearCache(); + await promiseStopServer(server); +} + +add_task(async function test_modified_after_fail() { + let engine = Service.engineManager.get("prefs"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + // The homepage pref is synced by default. + _("Set homepage before first sync"); + Services.prefs.setStringPref("browser.startup.homepage", "about:welcome"); + + _("First sync; create collection and pref record on server"); + await sync_engine_and_validate_telem(engine, false); + + let collection = server.user("foo").collection("prefs"); + equal( + collection.cleartext(PREFS_GUID).value["browser.startup.homepage"], + "about:welcome", + "Should upload homepage in pref record" + ); + ok( + !engine._tracker.modified, + "Tracker shouldn't be modified after first sync" + ); + + // Our tracker only has a `modified` flag that's reset after a + // successful upload. Force it to remain set by failing the + // upload. + _("Second sync; flag tracker as modified and throw on upload"); + Services.prefs.setStringPref("browser.startup.homepage", "about:robots"); + engine._tracker.modified = true; + let oldPost = collection.post; + collection.post = () => { + throw new Error("Sync this!"); + }; + await Assert.rejects( + sync_engine_and_validate_telem(engine, true), + ex => ex.success === false + ); + ok( + engine._tracker.modified, + "Tracker should remain modified after failed sync" + ); + + _("Third sync"); + collection.post = oldPost; + await sync_engine_and_validate_telem(engine, false); + equal( + collection.cleartext(PREFS_GUID).value["browser.startup.homepage"], + "about:robots", + "Should upload new homepage on third sync" + ); + ok( + !engine._tracker.modified, + "Tracker shouldn't be modified again after third sync" + ); + } finally { + await cleanup(engine, server); + } +}); + +add_task(async function test_allow_arbitrary() { + let engine = Service.engineManager.get("prefs"); + + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + try { + _("Create collection and pref record on server"); + await sync_engine_and_validate_telem(engine, false); + + let collection = server.user("foo").collection("prefs"); + + _("Insert arbitrary pref into remote record"); + let cleartext1 = collection.cleartext(PREFS_GUID); + cleartext1.value.let_viruses_take_over = true; + collection.insert( + PREFS_GUID, + encryptPayload(cleartext1), + new_timestamp() + 5 + ); + + _("Sync again; client shouldn't allow pref"); + await sync_engine_and_validate_telem(engine, false); + ok( + !Services.prefs.getBoolPref("let_viruses_take_over", false), + "Shouldn't allow arbitrary remote prefs without control pref" + ); + + _("Sync with control pref set; client should set new pref"); + Services.prefs.setBoolPref( + "services.sync.prefs.sync.let_viruses_take_over_take_two", + true + ); + + let cleartext2 = collection.cleartext(PREFS_GUID); + cleartext2.value.let_viruses_take_over_take_two = true; + collection.insert( + PREFS_GUID, + encryptPayload(cleartext2), + new_timestamp() + 5 + ); + // Reset the last sync time so that the engine fetches the record again. + await engine.setLastSync(0); + await sync_engine_and_validate_telem(engine, false); + ok( + Services.prefs.getBoolPref("let_viruses_take_over_take_two"), + "Should set arbitrary remote pref with control pref" + ); + } finally { + await cleanup(engine, server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_prefs_store.js b/src/zen/tests/mochitests/sync/test_prefs_store.js new file mode 100644 index 000000000..17f2af826 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_prefs_store.js @@ -0,0 +1,402 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Unable to arm timer, the object has been finalized\./ +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /IOUtils\.profileBeforeChange getter: IOUtils: profileBeforeChange phase has already finished/ +); + +const { PrefRec, getPrefsGUIDForTest } = ChromeUtils.importESModule( + "resource://services-sync/engines/prefs.sys.mjs" +); +const PREFS_GUID = getPrefsGUIDForTest(); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const DEFAULT_THEME_ID = "default-theme@mozilla.org"; +const COMPACT_THEME_ID = "firefox-compact-light@mozilla.org"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" +); +AddonTestUtils.overrideCertDB(); + +let prefsWithUserValue = new Set(); +add_setup(function record_prefsWithUserValue() { + for (const pref of Services.prefs.getChildList("")) { + if (Services.prefs.prefHasUserValue(pref)) { + prefsWithUserValue.add(pref); + } + } +}); + +function clearValues() { + for (const pref of Services.prefs.getChildList("")) { + if (!prefsWithUserValue.has(pref)) { + Services.prefs.clearUserPref(pref); + } + } +} + +add_task(async function run_test() { + _("Test fixtures."); + // Part of this test ensures the default theme, via the preference + // extensions.activeThemeID, is synced correctly - so we do a little + // addons initialization to allow this to work. + + // Enable application scopes to ensure the builtin theme is going to + // be installed as part of the the addon manager startup. + Services.prefs.setIntPref( + "extensions.enabledScopes", + AddonManager.SCOPE_APPLICATION + ); + await AddonTestUtils.promiseStartupManager(); + + // Install another built-in theme. + await AddonManager.installBuiltinAddon("resource://builtin-themes/light/"); + + const defaultThemeAddon = await AddonManager.getAddonByID(DEFAULT_THEME_ID); + ok(defaultThemeAddon, "Got an addon wrapper for the default theme"); + + const otherThemeAddon = await AddonManager.getAddonByID(COMPACT_THEME_ID); + ok(otherThemeAddon, "Got an addon wrapper for the compact theme"); + + await otherThemeAddon.enable(); + + // read our custom prefs file before doing anything. + Services.prefs.readDefaultPrefsFromFile( + do_get_file("prefs_test_prefs_store.js") + ); + + let engine = Service.engineManager.get("prefs"); + let store = engine._store; + try { + _("Expect the compact light theme to be active"); + Assert.strictEqual( + Services.prefs.getStringPref("extensions.activeThemeID"), + COMPACT_THEME_ID + ); + + _("The GUID corresponds to XUL App ID."); + let allIDs = await store.getAllIDs(); + let ids = Object.keys(allIDs); + Assert.equal(ids.length, 1); + Assert.equal(ids[0], PREFS_GUID); + Assert.ok(allIDs[PREFS_GUID]); + + Assert.ok(await store.itemExists(PREFS_GUID)); + Assert.equal(false, await store.itemExists("random-gibberish")); + + _("Unknown prefs record is created as deleted."); + let record = await store.createRecord("random-gibberish", "prefs"); + Assert.ok(record.deleted); + + _("Prefs record contains only prefs that should be synced."); + record = await store.createRecord(PREFS_GUID, "prefs"); + Assert.strictEqual(record.value["testing.int"], 123); + Assert.strictEqual(record.value["testing.string"], "ohai"); + Assert.strictEqual(record.value["testing.bool"], true); + // non-existing prefs get null as the value + Assert.strictEqual(record.value["testing.nonexistent"], null); + // as do prefs that have a default value. + Assert.strictEqual(record.value["testing.default"], null); + Assert.strictEqual(record.value["testing.turned.off"], undefined); + Assert.strictEqual(record.value["testing.not.turned.on"], undefined); + + _("Prefs record contains the correct control prefs."); + // All control prefs which have the default value and where the pref + // itself is synced should appear, but with null as the value. + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.int"], + null + ); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.string"], + null + ); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.bool"], + null + ); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.dont.change"], + null + ); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.nonexistent"], + null + ); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.default"], + null + ); + + // but this control pref has a non-default value so that value is synced. + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.turned.off"], + false + ); + + _("Unsyncable prefs are treated correctly."); + // Prefs we consider unsyncable (since they are URLs that won't be stable on + // another firefox) shouldn't be included - neither the value nor the + // control pref should appear. + Assert.strictEqual(record.value["testing.unsynced.url"], undefined); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.unsynced.url"], + undefined + ); + // Other URLs with user prefs should be synced, though. + Assert.strictEqual( + record.value["testing.synced.url"], + "https://www.example.com" + ); + Assert.strictEqual( + record.value["services.sync.prefs.sync.testing.synced.url"], + null + ); + + _("Update some prefs, including one that's to be reset/deleted."); + // This pref is not going to be reset or deleted as there's no "control pref" + // in either the incoming record or locally. + Services.prefs.setStringPref( + "testing.deleted-without-control-pref", + "I'm deleted-without-control-pref" + ); + // Another pref with only a local control pref. + Services.prefs.setStringPref( + "testing.deleted-with-local-control-pref", + "I'm deleted-with-local-control-pref" + ); + Services.prefs.setBoolPref( + "services.sync.prefs.sync.testing.deleted-with-local-control-pref", + true + ); + // And a pref without a local control pref but one that's incoming. + Services.prefs.setStringPref( + "testing.deleted-with-incoming-control-pref", + "I'm deleted-with-incoming-control-pref" + ); + record = new PrefRec("prefs", PREFS_GUID); + record.value = { + "extensions.activeThemeID": DEFAULT_THEME_ID, + "testing.int": 42, + "testing.string": "im in ur prefs", + "testing.bool": false, + "testing.deleted-without-control-pref": null, + "testing.deleted-with-local-control-pref": null, + "testing.deleted-with-incoming-control-pref": null, + "services.sync.prefs.sync.testing.deleted-with-incoming-control-pref": true, + "testing.somepref": "im a new pref from other device", + "services.sync.prefs.sync.testing.somepref": true, + // Pretend some a stale remote client is overwriting it with a value + // we consider unsyncable. + "testing.synced.url": "blob:ebeb707a-502e-40c6-97a5-dd4bda901463", + // Make sure we can replace the unsynced URL with a valid URL. + "testing.unsynced.url": "https://www.example.com/2", + // Make sure our "master control pref" is ignored. + "services.sync.prefs.dangerously_allow_arbitrary": true, + "services.sync.prefs.sync.services.sync.prefs.dangerously_allow_arbitrary": true, + }; + + const onceAddonEnabled = AddonTestUtils.promiseAddonEvent("onEnabled"); + + await store.update(record); + Assert.strictEqual(Services.prefs.getIntPref("testing.int"), 42); + Assert.strictEqual( + Services.prefs.getStringPref("testing.string"), + "im in ur prefs" + ); + Assert.strictEqual(Services.prefs.getBoolPref("testing.bool"), false); + Assert.strictEqual( + Services.prefs.getStringPref("testing.deleted-without-control-pref"), + "I'm deleted-without-control-pref" + ); + Assert.strictEqual( + Services.prefs.getPrefType("testing.deleted-with-local-control-pref"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.strictEqual( + Services.prefs.getStringPref( + "testing.deleted-with-incoming-control-pref" + ), + "I'm deleted-with-incoming-control-pref" + ); + Assert.strictEqual( + Services.prefs.getStringPref("testing.dont.change"), + "Please don't change me." + ); + Assert.strictEqual( + Services.prefs.getPrefType("testing.somepref"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.strictEqual( + Services.prefs.getStringPref("testing.synced.url"), + "https://www.example.com" + ); + Assert.strictEqual( + Services.prefs.getStringPref("testing.unsynced.url"), + "https://www.example.com/2" + ); + Assert.strictEqual( + Svc.PrefBranch.getPrefType("prefs.sync.testing.somepref"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.strictEqual( + Services.prefs.getBoolPref( + "services.sync.prefs.dangerously_allow_arbitrary" + ), + false + ); + Assert.strictEqual( + Services.prefs.getPrefType( + "services.sync.prefs.sync.services.sync.prefs.dangerously_allow_arbitrary" + ), + Ci.nsIPrefBranch.PREF_INVALID + ); + + await onceAddonEnabled; + ok( + !defaultThemeAddon.userDisabled, + "the default theme should have been enabled" + ); + ok( + otherThemeAddon.userDisabled, + "the compact theme should have been disabled" + ); + + _("Only the current app's preferences are applied."); + record = new PrefRec("prefs", "some-fake-app"); + record.value = { + "testing.int": 98, + }; + await store.update(record); + Assert.equal(Services.prefs.getIntPref("testing.int"), 42); + } finally { + clearValues(); + } +}); + +add_task(async function test_dangerously_allow() { + _("services.sync.prefs.dangerously_allow_arbitrary"); + // Bug 1538015 added a capability to "dangerously allow" arbitrary prefs. + // Bug 1854698 removed that capability but did keep the fact we never + // sync the pref which enabled the "dangerous" behaviour, just incase someone + // tries to sync it back to a profile which *does* support that pref. + Services.prefs.readDefaultPrefsFromFile( + do_get_file("prefs_test_prefs_store.js") + ); + + let engine = Service.engineManager.get("prefs"); + let store = engine._store; + try { + // an incoming record with our old "dangerous" pref. + let record = new PrefRec("prefs", PREFS_GUID); + record.value = { + "services.sync.prefs.dangerously_allow_arbitrary": true, + "services.sync.prefs.sync.services.sync.prefs.dangerously_allow_arbitrary": true, + }; + await store.update(record); + Assert.strictEqual( + Services.prefs.getBoolPref( + "services.sync.prefs.dangerously_allow_arbitrary" + ), + false + ); + Assert.strictEqual( + Services.prefs.getPrefType( + "services.sync.prefs.sync.services.sync.prefs.dangerously_allow_arbitrary" + ), + Ci.nsIPrefBranch.PREF_INVALID + ); + } finally { + clearValues(); + } +}); + +add_task(async function test_incoming_sets_seen() { + _("Test the sync-seen allow-list"); + + let engine = Service.engineManager.get("prefs"); + let store = engine._store; + + Services.prefs.readDefaultPrefsFromFile( + do_get_file("prefs_test_prefs_store.js") + ); + const defaultValue = "the value"; + Assert.equal(Services.prefs.getStringPref("testing.seen"), defaultValue); + + let record = await store.createRecord(PREFS_GUID, "prefs"); + // Haven't seen a non-default value before, so remains null. + Assert.strictEqual(record.value["testing.seen"], null); + + // pretend an incoming record with the default value - it might not be + // the default everywhere, so we treat it specially. + record = new PrefRec("prefs", PREFS_GUID); + record.value = { + "testing.seen": defaultValue, + }; + await store.update(record); + // Our special control value should now be set. + Assert.strictEqual( + Services.prefs.getBoolPref("services.sync.prefs.sync-seen.testing.seen"), + true + ); + // It's still the default value, so the value is not considered changed + Assert.equal(Services.prefs.prefHasUserValue("testing.seen"), false); + + // But now that special control value is set, the record always contains the value. + record = await store.createRecord(PREFS_GUID, "prefs"); + Assert.strictEqual(record.value["testing.seen"], defaultValue); +}); + +add_task(async function test_outgoing_when_changed() { + _("Test the 'seen' pref is set first sync of non-default value"); + + let engine = Service.engineManager.get("prefs"); + let store = engine._store; + clearValues(); + + Services.prefs.readDefaultPrefsFromFile( + do_get_file("prefs_test_prefs_store.js") + ); + const defaultValue = "the value"; + Assert.equal(Services.prefs.getStringPref("testing.seen"), defaultValue); + + let record = await store.createRecord(PREFS_GUID, "prefs"); + // Haven't seen a non-default value before, so remains null. + Assert.strictEqual(record.value["testing.seen"], null); + + // Change the value. + Services.prefs.setStringPref("testing.seen", "new value"); + record = await store.createRecord(PREFS_GUID, "prefs"); + // creating the record toggled that "seen" pref. + Assert.strictEqual( + Services.prefs.getBoolPref("services.sync.prefs.sync-seen.testing.seen"), + true + ); + Assert.strictEqual(Services.prefs.getStringPref("testing.seen"), "new value"); + + // Resetting the pref does not change that seen value. + Services.prefs.clearUserPref("testing.seen"); + Assert.strictEqual( + Services.prefs.getStringPref("testing.seen"), + defaultValue + ); + + record = await store.createRecord(PREFS_GUID, "prefs"); + Assert.strictEqual( + Services.prefs.getBoolPref("services.sync.prefs.sync-seen.testing.seen"), + true + ); +}); diff --git a/src/zen/tests/mochitests/sync/test_prefs_tracker.js b/src/zen/tests/mochitests/sync/test_prefs_tracker.js new file mode 100644 index 000000000..3e2740609 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_prefs_tracker.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +let prefsWithUserValue = new Set(); +add_setup(function record_prefsWithUserValue() { + for (const pref of Services.prefs.getChildList("")) { + if (Services.prefs.prefHasUserValue(pref)) { + prefsWithUserValue.add(pref); + } + } +}); + +function clearValues() { + for (const pref of Services.prefs.getChildList("")) { + if (!prefsWithUserValue.has(pref)) { + Services.prefs.clearUserPref(pref); + } + } +} + +add_task(async function run_test() { + let engine = Service.engineManager.get("prefs"); + let tracker = engine._tracker; + + try { + _("tracker.modified corresponds to preference."); + // Assert preference is not defined. + Assert.equal( + Svc.PrefBranch.getPrefType("engine.prefs.modified"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.ok(!tracker.modified); + + tracker.modified = true; + Assert.equal(Svc.PrefBranch.getBoolPref("engine.prefs.modified"), true); + Assert.ok(tracker.modified); + + _("Engine's getChangedID() just returns the one GUID we have."); + let changedIDs = await engine.getChangedIDs(); + let ids = Object.keys(changedIDs); + Assert.equal(ids.length, 1); + Assert.equal(ids[0], CommonUtils.encodeBase64URL(Services.appinfo.ID)); + + Svc.PrefBranch.setBoolPref("engine.prefs.modified", false); + Assert.ok(!tracker.modified); + + _("No modified state, so no changed IDs."); + do_check_empty(await engine.getChangedIDs()); + + _("Initial score is 0"); + Assert.equal(tracker.score, 0); + + _("Test fixtures."); + Svc.PrefBranch.setBoolPref("prefs.sync.testing.int", true); + + _( + "Test fixtures haven't upped the tracker score yet because it hasn't started tracking yet." + ); + Assert.equal(tracker.score, 0); + + _("Tell the tracker to start tracking changes."); + tracker.start(); + Services.prefs.setIntPref("testing.int", 23); + await tracker.asyncObserver.promiseObserversComplete(); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE); + Assert.equal(tracker.modified, true); + + _("Clearing changed IDs reset modified status."); + await tracker.clearChangedIDs(); + Assert.equal(tracker.modified, false); + + _("Resetting a pref ups the score, too."); + Services.prefs.clearUserPref("testing.int"); + await tracker.asyncObserver.promiseObserversComplete(); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 2); + Assert.equal(tracker.modified, true); + await tracker.clearChangedIDs(); + + _("So does changing a pref sync pref."); + Svc.PrefBranch.setBoolPref("prefs.sync.testing.int", false); + await tracker.asyncObserver.promiseObserversComplete(); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(tracker.modified, true); + await tracker.clearChangedIDs(); + + _( + "Now that the pref sync pref has been flipped, changes to it won't be picked up." + ); + Services.prefs.setIntPref("testing.int", 42); + await tracker.asyncObserver.promiseObserversComplete(); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(tracker.modified, false); + await tracker.clearChangedIDs(); + + _("Changing some other random pref won't do anything."); + Services.prefs.setStringPref("testing.other", "blergh"); + await tracker.asyncObserver.promiseObserversComplete(); + Assert.equal(tracker.score, SCORE_INCREMENT_XLARGE * 3); + Assert.equal(tracker.modified, false); + } finally { + await tracker.stop(); + clearValues(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_records_crypto.js b/src/zen/tests/mochitests/sync/test_records_crypto.js new file mode 100644 index 000000000..abdf89427 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_records_crypto.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CollectionKeyManager, CryptoWrapper } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +var cryptoWrap; + +function crypted_resource_handler(metadata, response) { + let obj = { + id: "resource", + modified: cryptoWrap.modified, + payload: JSON.stringify(cryptoWrap.payload), + }; + return httpd_basic_auth_handler(JSON.stringify(obj), metadata, response); +} + +function prepareCryptoWrap(collection, id) { + let w = new CryptoWrapper(); + w.cleartext.stuff = "my payload here"; + w.collection = collection; + w.id = id; + return w; +} + +add_task(async function test_records_crypto() { + let server; + + await configureIdentity({ username: "john@example.com" }); + let keyBundle = Service.identity.syncKeyBundle; + + try { + let log = Log.repository.getLogger("Test"); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + log.info("Setting up server and authenticator"); + + server = httpd_setup({ "/steam/resource": crypted_resource_handler }); + + log.info("Creating a record"); + + cryptoWrap = prepareCryptoWrap("steam", "resource"); + + log.info("cryptoWrap: " + cryptoWrap.toString()); + + log.info("Encrypting a record"); + + await cryptoWrap.encrypt(keyBundle); + log.info("Ciphertext is " + cryptoWrap.ciphertext); + Assert.notEqual(cryptoWrap.ciphertext, null); + + let firstIV = cryptoWrap.IV; + + log.info("Decrypting the record"); + + let payload = await cryptoWrap.decrypt(keyBundle); + Assert.equal(payload.stuff, "my payload here"); + Assert.notEqual(payload, cryptoWrap.payload); // wrap.data.payload is the encrypted one + + log.info("Make sure multiple decrypts cause failures"); + let error = ""; + try { + payload = await cryptoWrap.decrypt(keyBundle); + } catch (ex) { + error = ex; + } + Assert.equal(error.message, "No ciphertext: nothing to decrypt?"); + + log.info("Re-encrypting the record with alternate payload"); + + cryptoWrap.cleartext.stuff = "another payload"; + await cryptoWrap.encrypt(keyBundle); + let secondIV = cryptoWrap.IV; + payload = await cryptoWrap.decrypt(keyBundle); + Assert.equal(payload.stuff, "another payload"); + + log.info("Make sure multiple encrypts use different IVs"); + Assert.notEqual(firstIV, secondIV); + + log.info(await "Make sure differing ids cause failures"); + await cryptoWrap.encrypt(keyBundle); + cryptoWrap.data.id = "other"; + error = ""; + try { + await cryptoWrap.decrypt(keyBundle); + } catch (ex) { + error = ex; + } + Assert.equal(error.message, "Record id mismatch: resource != other"); + + log.info("Make sure wrong hmacs cause failures"); + await cryptoWrap.encrypt(keyBundle); + cryptoWrap.hmac = "foo"; + error = ""; + try { + await cryptoWrap.decrypt(keyBundle); + } catch (ex) { + error = ex; + } + Assert.equal( + error.message.substr(0, 42), + "Record SHA256 HMAC mismatch: should be foo" + ); + + // Checking per-collection keys and default key handling. + + await generateNewKeys(Service.collectionKeys); + let bookmarkItem = prepareCryptoWrap("bookmarks", "foo"); + await bookmarkItem.encrypt( + Service.collectionKeys.keyForCollection("bookmarks") + ); + log.info("Ciphertext is " + bookmarkItem.ciphertext); + Assert.notEqual(bookmarkItem.ciphertext, null); + log.info("Decrypting the record explicitly with the default key."); + Assert.equal( + (await bookmarkItem.decrypt(Service.collectionKeys._default)).stuff, + "my payload here" + ); + + // Per-collection keys. + // Generate a key for "bookmarks". + await generateNewKeys(Service.collectionKeys, ["bookmarks"]); + bookmarkItem = prepareCryptoWrap("bookmarks", "foo"); + Assert.equal(bookmarkItem.collection, "bookmarks"); + + // Encrypt. This'll use the "bookmarks" encryption key, because we have a + // special key for it. The same key will need to be used for decryption. + await bookmarkItem.encrypt( + Service.collectionKeys.keyForCollection("bookmarks") + ); + Assert.notEqual(bookmarkItem.ciphertext, null); + + // Attempt to use the default key, because this is a collision that could + // conceivably occur in the real world. Decryption will error, because + // it's not the bookmarks key. + let err; + try { + await bookmarkItem.decrypt(Service.collectionKeys._default); + } catch (ex) { + err = ex; + } + Assert.equal("Record SHA256 HMAC mismatch", err.message.substr(0, 27)); + + // Explicitly check that it's using the bookmarks key. + // This should succeed. + Assert.equal( + ( + await bookmarkItem.decrypt( + Service.collectionKeys.keyForCollection("bookmarks") + ) + ).stuff, + "my payload here" + ); + + Assert.ok(Service.collectionKeys.hasKeysFor(["bookmarks"])); + + // Add a key for some new collection and verify that it isn't the + // default key. + Assert.ok(!Service.collectionKeys.hasKeysFor(["forms"])); + Assert.ok(!Service.collectionKeys.hasKeysFor(["bookmarks", "forms"])); + let oldFormsKey = Service.collectionKeys.keyForCollection("forms"); + Assert.equal(oldFormsKey, Service.collectionKeys._default); + let newKeys = await Service.collectionKeys.ensureKeysFor(["forms"]); + Assert.ok(newKeys.hasKeysFor(["forms"])); + Assert.ok(newKeys.hasKeysFor(["bookmarks", "forms"])); + let newFormsKey = newKeys.keyForCollection("forms"); + Assert.notEqual(newFormsKey, oldFormsKey); + + // Verify that this doesn't overwrite keys + let regetKeys = await newKeys.ensureKeysFor(["forms"]); + Assert.equal(regetKeys.keyForCollection("forms"), newFormsKey); + + const emptyKeys = new CollectionKeyManager(); + payload = { + default: Service.collectionKeys._default.keyPairB64, + collections: {}, + }; + // Verify that not passing `modified` doesn't throw + emptyKeys.setContents(payload, null); + + log.info("Done!"); + } finally { + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_records_wbo.js b/src/zen/tests/mochitests/sync/test_records_wbo.js new file mode 100644 index 000000000..035d5ac5f --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_records_wbo.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { WBORecord, Collection } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Resource } = ChromeUtils.importESModule( + "resource://services-sync/resource.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_test(function test_toJSON() { + _("Create a record, for now without a TTL."); + let wbo = new WBORecord("coll", "a_record"); + wbo.modified = 12345; + wbo.sortindex = 42; + wbo.payload = {}; + + _( + "Verify that the JSON representation contains the WBO properties, but not TTL." + ); + let json = JSON.parse(JSON.stringify(wbo)); + Assert.equal(json.modified, 12345); + Assert.equal(json.sortindex, 42); + Assert.equal(json.payload, "{}"); + Assert.equal(false, "ttl" in json); + + _("Set a TTL, make sure it's present in the JSON representation."); + wbo.ttl = 30 * 60; + json = JSON.parse(JSON.stringify(wbo)); + Assert.equal(json.ttl, 30 * 60); + run_next_test(); +}); + +add_task(async function test_fetch() { + let record = { + id: "asdf-1234-asdf-1234", + modified: 2454725.98283, + payload: JSON.stringify({ cheese: "roquefort" }), + }; + let record2 = { + id: "record2", + modified: 2454725.98284, + payload: JSON.stringify({ cheese: "gruyere" }), + }; + let coll = [ + { + id: "record2", + modified: 2454725.98284, + payload: JSON.stringify({ cheese: "gruyere" }), + }, + ]; + + _("Setting up server."); + let server = httpd_setup({ + "/record": httpd_handler(200, "OK", JSON.stringify(record)), + "/record2": httpd_handler(200, "OK", JSON.stringify(record2)), + "/coll": httpd_handler(200, "OK", JSON.stringify(coll)), + }); + + try { + _("Fetching a WBO record"); + let rec = new WBORecord("coll", "record"); + await rec.fetch(Service.resource(server.baseURI + "/record")); + Assert.equal(rec.id, "asdf-1234-asdf-1234"); // NOT "record"! + + Assert.equal(rec.modified, 2454725.98283); + Assert.equal(typeof rec.payload, "object"); + Assert.equal(rec.payload.cheese, "roquefort"); + + _("Fetching a WBO record using the record manager"); + let rec2 = await Service.recordManager.get(server.baseURI + "/record2"); + Assert.equal(rec2.id, "record2"); + Assert.equal(rec2.modified, 2454725.98284); + Assert.equal(typeof rec2.payload, "object"); + Assert.equal(rec2.payload.cheese, "gruyere"); + Assert.equal(Service.recordManager.response.status, 200); + + // Testing collection extraction. + _("Extracting collection."); + let rec3 = new WBORecord("tabs", "foo"); // Create through constructor. + Assert.equal(rec3.collection, "tabs"); + } finally { + await promiseStopServer(server); + } +}); + +class FakeService { + constructor(storageURL) { + this.storageURL = storageURL; + } + resource(uri) { + // allow ease of ctor without auth. + return new Resource(uri); + } +} + +add_task(async function test_collection_invalid_url_handling() { + const service = new FakeService("http://example.com/storage/"); + + // Invalid string: Resource will leave coll.uri === null + const coll = new Collection("not-a-valid-url", WBORecord, service); + + // Verify the error reporting scenario is handled + Assert.throws(() => { + coll.full = true; // Triggers _rebuildURL + }, /_rebuildURL called with null uri/); +}); + +add_task(async function test_wborecord_uri_makeURI_null() { + let record = new WBORecord("tabs", "abc123"); + + // Test that we get a helpful error when makeURI returns null + Assert.throws(() => { + record.uri("not-a-valid-base-url"); + }, /WBORecord\.uri\(\): makeURI returned null for base=/); +}); diff --git a/src/zen/tests/mochitests/sync/test_resource.js b/src/zen/tests/mochitests/sync/test_resource.js new file mode 100644 index 000000000..5dee57b39 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_resource.js @@ -0,0 +1,554 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Observers } = ChromeUtils.importESModule( + "resource://services-common/observers.sys.mjs" +); +const { Resource } = ChromeUtils.importESModule( + "resource://services-sync/resource.sys.mjs" +); +const { SyncAuthManager } = ChromeUtils.importESModule( + "resource://services-sync/sync_auth.sys.mjs" +); + +var fetched = false; +function server_open(metadata, response) { + let body; + if (metadata.method == "GET") { + fetched = true; + body = "This path exists"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Wrong request method"; + response.setStatusLine(metadata.httpVersion, 405, "Method Not Allowed"); + } + response.bodyOutputStream.write(body, body.length); +} + +function server_protected(metadata, response) { + let body; + + if (has_hawk_header(metadata)) { + body = "This path exists and is protected"; + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } else { + body = "This path exists and is protected - failed"; + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } + + response.bodyOutputStream.write(body, body.length); +} + +function server_404(metadata, response) { + let body = "File not found"; + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.bodyOutputStream.write(body, body.length); +} + +var pacFetched = false; +function server_pac(metadata, response) { + _("Invoked PAC handler."); + pacFetched = true; + let body = 'function FindProxyForURL(url, host) { return "DIRECT"; }'; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader( + "Content-Type", + "application/x-ns-proxy-autoconfig", + false + ); + response.bodyOutputStream.write(body, body.length); +} + +var sample_data = { + some: "sample_data", + injson: "format", + number: 42, +}; + +function server_upload(metadata, response) { + let body; + + let input = readBytesFromInputStream(metadata.bodyInputStream); + if (input == JSON.stringify(sample_data)) { + body = "Valid data upload via " + metadata.method; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Invalid data upload via " + metadata.method + ": " + input; + response.setStatusLine(metadata.httpVersion, 500, "Internal Server Error"); + } + + response.bodyOutputStream.write(body, body.length); +} + +function server_delete(metadata, response) { + let body; + if (metadata.method == "DELETE") { + body = "This resource has been deleted"; + response.setStatusLine(metadata.httpVersion, 200, "OK"); + } else { + body = "Wrong request method"; + response.setStatusLine(metadata.httpVersion, 405, "Method Not Allowed"); + } + response.bodyOutputStream.write(body, body.length); +} + +function server_json(metadata, response) { + let body = JSON.stringify(sample_data); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +const TIMESTAMP = 1274380461; + +function server_timestamp(metadata, response) { + let body = "Thank you for your request"; + response.setHeader("X-Weave-Timestamp", "" + TIMESTAMP, false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_backoff(metadata, response) { + let body = "Hey, back off!"; + response.setHeader("X-Weave-Backoff", "600", false); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_quota_notice(request, response) { + let body = "You're approaching quota."; + response.setHeader("X-Weave-Quota-Remaining", "1048576", false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_quota_error(request, response) { + let body = "14"; + response.setHeader("X-Weave-Quota-Remaining", "-1024", false); + response.setStatusLine(request.httpVersion, 400, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +function server_headers(metadata, response) { + let ignore_headers = [ + "host", + "user-agent", + "accept-language", + "accept-encoding", + "accept-charset", + "keep-alive", + "connection", + "pragma", + "origin", + "cache-control", + "content-length", + ]; + let headers = metadata.headers; + let header_names = []; + while (headers.hasMoreElements()) { + let header = headers.getNext().toString(); + if (!ignore_headers.includes(header)) { + header_names.push(header); + } + } + header_names = header_names.sort(); + + headers = {}; + for (let header of header_names) { + headers[header] = metadata.getHeader(header); + } + let body = JSON.stringify(headers); + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); +} + +var quotaValue; +Observers.add("weave:service:quota:remaining", function (subject) { + quotaValue = subject; +}); + +function run_test() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + Svc.PrefBranch.setIntPref("network.numRetries", 1); // speed up test + run_next_test(); +} + +// This apparently has to come first in order for our PAC URL to be hit. +// Don't put any other HTTP requests earlier in the file! +add_task(async function test_proxy_auth_redirect() { + _( + "Ensure that a proxy auth redirect (which switches out our channel) " + + "doesn't break Resource." + ); + let server = httpd_setup({ + "/open": server_open, + "/pac2": server_pac, + }); + + PACSystemSettings.PACURI = server.baseURI + "/pac2"; + installFakePAC(); + let res = new Resource(server.baseURI + "/open"); + let result = await res.get(); + Assert.ok(pacFetched); + Assert.ok(fetched); + Assert.equal("This path exists", result.data); + pacFetched = fetched = false; + uninstallFakePAC(); + await promiseStopServer(server); +}); + +add_task(async function test_new_channel() { + _("Ensure a redirect to a new channel is handled properly."); + + let resourceRequested = false; + function resourceHandler(metadata, response) { + resourceRequested = true; + + let body = "Test"; + response.setHeader("Content-Type", "text/plain"); + response.bodyOutputStream.write(body, body.length); + } + + let locationURL; + function redirectHandler(metadata, response) { + let body = "Redirecting"; + response.setStatusLine(metadata.httpVersion, 307, "TEMPORARY REDIRECT"); + response.setHeader("Location", locationURL); + response.bodyOutputStream.write(body, body.length); + } + + let server = httpd_setup({ + "/resource": resourceHandler, + "/redirect": redirectHandler, + }); + locationURL = server.baseURI + "/resource"; + + let request = new Resource(server.baseURI + "/redirect"); + let content = await request.get(); + Assert.ok(resourceRequested); + Assert.equal(200, content.status); + Assert.ok("content-type" in content.headers); + Assert.equal("text/plain", content.headers["content-type"]); + + await promiseStopServer(server); +}); + +var server; + +add_test(function setup() { + server = httpd_setup({ + "/open": server_open, + "/protected": server_protected, + "/404": server_404, + "/upload": server_upload, + "/delete": server_delete, + "/json": server_json, + "/timestamp": server_timestamp, + "/headers": server_headers, + "/backoff": server_backoff, + "/pac2": server_pac, + "/quota-notice": server_quota_notice, + "/quota-error": server_quota_error, + }); + + run_next_test(); +}); + +add_test(function test_members() { + _("Resource object members"); + let uri = server.baseURI + "/open"; + let res = new Resource(uri); + Assert.ok(res.uri instanceof Ci.nsIURI); + Assert.equal(res.uri.spec, uri); + Assert.equal(res.spec, uri); + Assert.equal(typeof res.headers, "object"); + Assert.equal(typeof res.authenticator, "object"); + + run_next_test(); +}); + +add_task(async function test_get() { + _("GET a non-password-protected resource"); + let res = new Resource(server.baseURI + "/open"); + let content = await res.get(); + Assert.equal(content.data, "This path exists"); + Assert.equal(content.status, 200); + Assert.ok(content.success); + + // Observe logging messages. + let resLogger = res._log; + let dbg = resLogger.debug; + let debugMessages = []; + resLogger.debug = function (msg, extra) { + debugMessages.push(`${msg}: ${JSON.stringify(extra)}`); + dbg.call(this, msg); + }; + + // Since we didn't receive proper JSON data, accessing content.obj + // will result in a SyntaxError from JSON.parse + let didThrow = false; + try { + content.obj; + } catch (ex) { + didThrow = true; + } + Assert.ok(didThrow); + Assert.equal(debugMessages.length, 1); + Assert.equal( + debugMessages[0], + 'Parse fail: Response body starts: "This path exists"' + ); + resLogger.debug = dbg; +}); + +add_test(function test_basicauth() { + _("Test that the BasicAuthenticator doesn't screw up header case."); + let res1 = new Resource(server.baseURI + "/foo"); + res1.setHeader("Authorization", "Basic foobar"); + Assert.equal(res1._headers.authorization, "Basic foobar"); + Assert.equal(res1.headers.authorization, "Basic foobar"); + + run_next_test(); +}); + +add_task(async function test_get_protected_fail() { + _( + "GET a password protected resource (test that it'll fail w/o pass, no throw)" + ); + let res2 = new Resource(server.baseURI + "/protected"); + let content = await res2.get(); + Assert.equal(content.data, "This path exists and is protected - failed"); + Assert.equal(content.status, 401); + Assert.ok(!content.success); +}); + +add_task(async function test_get_protected_success() { + _("GET a password protected resource"); + let identityConfig = makeIdentityConfig(); + let syncAuthManager = new SyncAuthManager(); + configureFxAccountIdentity(syncAuthManager, identityConfig); + let auth = syncAuthManager.getResourceAuthenticator(); + let res3 = new Resource(server.baseURI + "/protected"); + res3.authenticator = auth; + Assert.equal(res3.authenticator, auth); + let content = await res3.get(); + Assert.equal(content.data, "This path exists and is protected"); + Assert.equal(content.status, 200); + Assert.ok(content.success); +}); + +add_task(async function test_get_404() { + _("GET a non-existent resource (test that it'll fail, but not throw)"); + let res4 = new Resource(server.baseURI + "/404"); + let content = await res4.get(); + Assert.equal(content.data, "File not found"); + Assert.equal(content.status, 404); + Assert.ok(!content.success); + + // Check some headers of the 404 response + Assert.equal(content.headers.connection, "close"); + Assert.equal(content.headers.server, "httpd.js"); + Assert.equal(content.headers["content-length"], 14); +}); + +add_task(async function test_put_string() { + _("PUT to a resource (string)"); + let res_upload = new Resource(server.baseURI + "/upload"); + let content = await res_upload.put(JSON.stringify(sample_data)); + Assert.equal(content.data, "Valid data upload via PUT"); + Assert.equal(content.status, 200); +}); + +add_task(async function test_put_object() { + _("PUT to a resource (object)"); + let res_upload = new Resource(server.baseURI + "/upload"); + let content = await res_upload.put(sample_data); + Assert.equal(content.data, "Valid data upload via PUT"); + Assert.equal(content.status, 200); +}); + +add_task(async function test_post_string() { + _("POST to a resource (string)"); + let res_upload = new Resource(server.baseURI + "/upload"); + let content = await res_upload.post(JSON.stringify(sample_data)); + Assert.equal(content.data, "Valid data upload via POST"); + Assert.equal(content.status, 200); +}); + +add_task(async function test_post_object() { + _("POST to a resource (object)"); + let res_upload = new Resource(server.baseURI + "/upload"); + let content = await res_upload.post(sample_data); + Assert.equal(content.data, "Valid data upload via POST"); + Assert.equal(content.status, 200); +}); + +add_task(async function test_delete() { + _("DELETE a resource"); + let res6 = new Resource(server.baseURI + "/delete"); + let content = await res6.delete(); + Assert.equal(content.data, "This resource has been deleted"); + Assert.equal(content.status, 200); +}); + +add_task(async function test_json_body() { + _("JSON conversion of response body"); + let res7 = new Resource(server.baseURI + "/json"); + let content = await res7.get(); + Assert.equal(content.data, JSON.stringify(sample_data)); + Assert.equal(content.status, 200); + Assert.equal(JSON.stringify(content.obj), JSON.stringify(sample_data)); +}); + +add_task(async function test_weave_timestamp() { + _("X-Weave-Timestamp header updates Resource.serverTime"); + // Before having received any response containing the + // X-Weave-Timestamp header, Resource.serverTime is null. + Assert.equal(Resource.serverTime, null); + let res8 = new Resource(server.baseURI + "/timestamp"); + await res8.get(); + Assert.equal(Resource.serverTime, TIMESTAMP); +}); + +add_task(async function test_get_default_headers() { + _("GET: Accept defaults to application/json"); + let res_headers = new Resource(server.baseURI + "/headers"); + let content = JSON.parse((await res_headers.get()).data); + Assert.equal(content.accept, "application/json;q=0.9,*/*;q=0.2"); +}); + +add_task(async function test_put_default_headers() { + _( + "PUT: Accept defaults to application/json, Content-Type defaults to text/plain" + ); + let res_headers = new Resource(server.baseURI + "/headers"); + let content = JSON.parse((await res_headers.put("data")).data); + Assert.equal(content.accept, "application/json;q=0.9,*/*;q=0.2"); + Assert.equal(content["content-type"], "text/plain"); +}); + +add_task(async function test_post_default_headers() { + _( + "POST: Accept defaults to application/json, Content-Type defaults to text/plain" + ); + let res_headers = new Resource(server.baseURI + "/headers"); + let content = JSON.parse((await res_headers.post("data")).data); + Assert.equal(content.accept, "application/json;q=0.9,*/*;q=0.2"); + Assert.equal(content["content-type"], "text/plain"); +}); + +add_task(async function test_setHeader() { + _("setHeader(): setting simple header"); + let res_headers = new Resource(server.baseURI + "/headers"); + res_headers.setHeader("X-What-Is-Weave", "awesome"); + Assert.equal(res_headers.headers["x-what-is-weave"], "awesome"); + let content = JSON.parse((await res_headers.get()).data); + Assert.equal(content["x-what-is-weave"], "awesome"); +}); + +add_task(async function test_setHeader_overwrite() { + _("setHeader(): setting multiple headers, overwriting existing header"); + let res_headers = new Resource(server.baseURI + "/headers"); + res_headers.setHeader("X-WHAT-is-Weave", "more awesomer"); + res_headers.setHeader("X-Another-Header", "hello world"); + Assert.equal(res_headers.headers["x-what-is-weave"], "more awesomer"); + Assert.equal(res_headers.headers["x-another-header"], "hello world"); + let content = JSON.parse((await res_headers.get()).data); + Assert.equal(content["x-what-is-weave"], "more awesomer"); + Assert.equal(content["x-another-header"], "hello world"); +}); + +add_task(async function test_put_override_content_type() { + _("PUT: override default Content-Type"); + let res_headers = new Resource(server.baseURI + "/headers"); + res_headers.setHeader("Content-Type", "application/foobar"); + Assert.equal(res_headers.headers["content-type"], "application/foobar"); + let content = JSON.parse((await res_headers.put("data")).data); + Assert.equal(content["content-type"], "application/foobar"); +}); + +add_task(async function test_post_override_content_type() { + _("POST: override default Content-Type"); + let res_headers = new Resource(server.baseURI + "/headers"); + res_headers.setHeader("Content-Type", "application/foobar"); + let content = JSON.parse((await res_headers.post("data")).data); + Assert.equal(content["content-type"], "application/foobar"); +}); + +add_task(async function test_weave_backoff() { + _("X-Weave-Backoff header notifies observer"); + let backoffInterval; + function onBackoff(subject) { + backoffInterval = subject; + } + Observers.add("weave:service:backoff:interval", onBackoff); + + let res10 = new Resource(server.baseURI + "/backoff"); + await res10.get(); + Assert.equal(backoffInterval, 600); +}); + +add_task(async function test_quota_error() { + _("X-Weave-Quota-Remaining header notifies observer on successful requests."); + let res10 = new Resource(server.baseURI + "/quota-error"); + let content = await res10.get(); + Assert.equal(content.status, 400); + Assert.equal(quotaValue, undefined); // HTTP 400, so no observer notification. +}); + +add_task(async function test_quota_notice() { + let res10 = new Resource(server.baseURI + "/quota-notice"); + let content = await res10.get(); + Assert.equal(content.status, 200); + Assert.equal(quotaValue, 1048576); +}); + +add_task(async function test_preserve_exceptions() { + _("Error handling preserves exception information"); + let res11 = new Resource("http://localhost:12345/does/not/exist"); + await Assert.rejects(res11.get(), error => { + Assert.notEqual(error, null); + Assert.equal(error.result, Cr.NS_ERROR_CONNECTION_REFUSED); + Assert.equal(error.name, "NS_ERROR_CONNECTION_REFUSED"); + return true; + }); +}); + +add_task(async function test_timeout() { + _("Ensure channel timeouts are thrown appropriately."); + let res19 = new Resource(server.baseURI + "/json"); + res19.ABORT_TIMEOUT = 0; + await Assert.rejects(res19.get(), error => { + Assert.equal(error.result, Cr.NS_ERROR_NET_TIMEOUT); + return true; + }); +}); + +add_test(function test_uri_construction() { + _("Testing URI construction."); + let args = []; + args.push("newer=" + 1234); + args.push("limit=" + 1234); + args.push("sort=" + 1234); + + let query = "?" + args.join("&"); + + let uri1 = CommonUtils.makeURI("http://foo/" + query).QueryInterface( + Ci.nsIURL + ); + let uri2 = CommonUtils.makeURI("http://foo/").QueryInterface(Ci.nsIURL); + uri2 = uri2.mutate().setQuery(query).finalize().QueryInterface(Ci.nsIURL); + Assert.equal(uri1.query, uri2.query); + + run_next_test(); +}); + +/** + * End of tests that rely on a single HTTP server. + * All tests after this point must begin and end their own. + */ +add_test(function eliminate_server() { + server.stop(run_next_test); +}); diff --git a/src/zen/tests/mochitests/sync/test_resource_header.js b/src/zen/tests/mochitests/sync/test_resource_header.js new file mode 100644 index 000000000..e45b4a986 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_resource_header.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Resource } = ChromeUtils.importESModule( + "resource://services-sync/resource.sys.mjs" +); + +var httpServer = new HttpServer(); +httpServer.registerPathHandler("/content", contentHandler); +httpServer.start(-1); + +const HTTP_PORT = httpServer.identity.primaryPort; +const TEST_URL = "http://localhost:" + HTTP_PORT + "/content"; +const BODY = "response body"; + +// Keep headers for later inspection. +var auth = null; +var foo = null; +function contentHandler(metadata, response) { + _("Handling request."); + auth = metadata.getHeader("Authorization"); + foo = metadata.getHeader("X-Foo"); + + _("Extracted headers. " + auth + ", " + foo); + + response.setHeader("Content-Type", "text/plain"); + response.bodyOutputStream.write(BODY, BODY.length); +} + +// Set a proxy function to cause an internal redirect. +function triggerRedirect() { + const PROXY_FUNCTION = + "function FindProxyForURL(url, host) {" + + " return 'PROXY a_non_existent_domain_x7x6c572v:80; " + + "PROXY localhost:" + + HTTP_PORT + + "';" + + "}"; + + let prefs = Services.prefs.getBranch("network.proxy."); + prefs.setIntPref("type", 2); + prefs.setStringPref("autoconfig_url", "data:text/plain," + PROXY_FUNCTION); +} + +add_task(async function test_headers_copied() { + triggerRedirect(); + + _("Issuing request."); + let resource = new Resource(TEST_URL); + resource.setHeader("Authorization", "Basic foobar"); + resource.setHeader("X-Foo", "foofoo"); + + let result = await resource.get(TEST_URL); + _("Result: " + result.data); + + Assert.equal(result.data, BODY); + Assert.equal(auth, "Basic foobar"); + Assert.equal(foo, "foofoo"); + + await promiseStopServer(httpServer); +}); diff --git a/src/zen/tests/mochitests/sync/test_resource_ua.js b/src/zen/tests/mochitests/sync/test_resource_ua.js new file mode 100644 index 000000000..115fa85b8 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_resource_ua.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Resource } = ChromeUtils.importESModule( + "resource://services-sync/resource.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +var httpProtocolHandler = Cc[ + "@mozilla.org/network/protocol;1?name=http" +].getService(Ci.nsIHttpProtocolHandler); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); + +var meta_global; +var server; + +var expectedUA; +var ua; +function uaHandler(f) { + return function (request, response) { + ua = request.getHeader("User-Agent"); + return f(request, response); + }; +} + +add_task(async function setup() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + meta_global = new ServerWBO("global"); + server = httpd_setup({ + "/1.1/johndoe/info/collections": uaHandler(collectionsHelper.handler), + "/1.1/johndoe/storage/meta/global": uaHandler(meta_global.handler()), + }); + + await configureIdentity({ username: "johndoe" }, server); + _("Server URL: " + server.baseURI); + + // Note this string is missing the trailing ".destkop" as the test + // adjusts the "client.type" pref where that portion comes from. + expectedUA = + Services.appinfo.name + + "/" + + Services.appinfo.version + + " (" + + httpProtocolHandler.oscpu + + ")" + + " FxSync/" + + WEAVE_VERSION + + "." + + Services.appinfo.appBuildID; +}); + +add_task(async function test_fetchInfo() { + _("Testing _fetchInfo."); + await Service.login(); + await Service._fetchInfo(); + _("User-Agent: " + ua); + Assert.equal(ua, expectedUA + ".desktop"); + ua = ""; +}); + +add_task(async function test_desktop_post() { + _("Testing direct Resource POST."); + let r = new Resource(server.baseURI + "/1.1/johndoe/storage/meta/global"); + await r.post("foo=bar"); + _("User-Agent: " + ua); + Assert.equal(ua, expectedUA + ".desktop"); + ua = ""; +}); + +add_task(async function test_desktop_get() { + _("Testing async."); + Svc.PrefBranch.setStringPref("client.type", "desktop"); + let r = new Resource(server.baseURI + "/1.1/johndoe/storage/meta/global"); + await r.get(); + _("User-Agent: " + ua); + Assert.equal(ua, expectedUA + ".desktop"); + ua = ""; +}); + +add_task(async function test_mobile_get() { + _("Testing mobile."); + Svc.PrefBranch.setStringPref("client.type", "mobile"); + let r = new Resource(server.baseURI + "/1.1/johndoe/storage/meta/global"); + await r.get(); + _("User-Agent: " + ua); + Assert.equal(ua, expectedUA + ".mobile"); + ua = ""; +}); + +add_test(function tear_down() { + server.stop(run_next_test); +}); diff --git a/src/zen/tests/mochitests/sync/test_rust_password_engine_mp_locked.js b/src/zen/tests/mochitests/sync/test_rust_password_engine_mp_locked.js new file mode 100644 index 000000000..a1aff815a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_rust_password_engine_mp_locked.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Bug 2057164: after the user cancels the Primary Password prompt, a background + * sync must not keep re-prompting on every subsequent sync. When the Primary + * Password is locked, the Rust-backed RustPasswordEngine must skip syncing + * *without* prompting and record the master-password-locked login state, so the + * scheduler backs off - matching the behaviour of the legacy PasswordEngine. + * + * RustPasswordEngine.initialize() reaches into the active Rust store for its + * bridge, so getActiveStore() is stubbed to avoid depending on the Rust backend. + */ + +"use strict"; + +/* import-globals-from head_errorhandler_common.js */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { RustPasswordEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { LoginManagerStorage } = ChromeUtils.importESModule( + "resource://passwordmgr/passwordstorage.sys.mjs" +); + +const PREF_ACTIVE = "signon.storage.rust.active"; + +// `isLoggedIn` is a non-configurable getter on the real Services.logins, so swap +// in a minimal stand-in for the duration of the check and restore it afterwards. +function stubLoggedIn(value) { + let oldLogins = Services.logins; + Services.logins = { isLoggedIn: value }; + return () => { + Services.logins = oldLogins; + }; +} + +let engine; +let sandbox; +let superSync; + +add_task(async function setup() { + await Service.promiseInitialized; + Services.prefs.clearUserPref(PREF_ACTIVE); + + sandbox = sinon.createSandbox(); + sandbox.stub(LoginManagerStorage, "getActiveStore").returns({ + bridgedEngine: async () => ({}), + }); + // Stub the inherited real sync so a call is observable (and wouldn't hit the + // network); RustPasswordEngine._sync() delegates to it when unlocked. + superSync = sandbox.stub(SyncEngine.prototype, "_sync").resolves(); + + Services.prefs.setBoolPref(PREF_ACTIVE, true); + await Service.engineManager.switchAlternatives(); + engine = Service.engineManager.get("passwords"); + Assert.ok( + engine instanceof RustPasswordEngine, + "the Rust-backed engine is active" + ); +}); + +add_task(async function test_skips_and_records_lock_when_locked() { + superSync.resetHistory(); + let restore = stubLoggedIn(false); + Service.status.login = LOGIN_SUCCEEDED; + + try { + await engine._sync(); + + Assert.ok( + superSync.notCalled, + "the engine skips the real sync (which would prompt) while locked" + ); + Assert.equal( + Service.status.login, + MASTER_PASSWORD_LOCKED, + "a locked Primary Password records the master-password-locked login state" + ); + } finally { + restore(); + } +}); + +add_task(async function test_syncs_normally_when_unlocked() { + superSync.resetHistory(); + let restore = stubLoggedIn(true); + Service.status.login = LOGIN_SUCCEEDED; + + try { + await engine._sync(); + + Assert.ok( + superSync.calledOnce, + "the engine performs a normal sync when the Primary Password is unlocked" + ); + Assert.equal( + Service.status.login, + LOGIN_SUCCEEDED, + "an unlocked Primary Password leaves the login state untouched" + ); + } finally { + restore(); + } +}); + +add_task(async function teardown() { + Services.prefs.clearUserPref(PREF_ACTIVE); + await Service.engineManager.switchAlternatives(); + sandbox.restore(); + Service.status.resetSync(); +}); diff --git a/src/zen/tests/mochitests/sync/test_rust_password_engine_selection.js b/src/zen/tests/mochitests/sync/test_rust_password_engine_selection.js new file mode 100644 index 000000000..fba7e9785 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_rust_password_engine_selection.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Flipping `signon.storage.rust.active` at runtime must switch the registered + * "passwords" engine between the legacy PasswordEngine and the Rust-backed + * RustPasswordEngine. The generic alternative machinery is covered by + * test_enginemanager.js; this guards the service.sys.mjs wiring against a + * key/name mismatch that would leave switchAlternatives() unable to swap the + * real engine. + * + * RustPasswordEngine.initialize() reaches into the active Rust store for its + * bridge, so getActiveStore() is stubbed to avoid depending on the Rust backend. + */ + +"use strict"; + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { PasswordEngine, RustPasswordEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); +const { LoginManagerStorage } = ChromeUtils.importESModule( + "resource://passwordmgr/passwordstorage.sys.mjs" +); + +const PREF_ACTIVE = "signon.storage.rust.active"; + +add_task(async function test_pref_flip_switches_engine() { + await Service.promiseInitialized; + Services.prefs.clearUserPref(PREF_ACTIVE); + + let sandbox = sinon.createSandbox(); + // RustPasswordEngine.initialize() does `getActiveStore().bridgedEngine()`. + sandbox.stub(LoginManagerStorage, "getActiveStore").returns({ + bridgedEngine: async () => ({}), + }); + + try { + Assert.ok( + Service.engineManager.get("passwords") instanceof PasswordEngine, + "the legacy JS engine is used while the pref is off" + ); + + Services.prefs.setBoolPref(PREF_ACTIVE, true); + await Service.engineManager.switchAlternatives(); + Assert.ok( + Service.engineManager.get("passwords") instanceof RustPasswordEngine, + "turning the pref on switches to the Rust-backed engine" + ); + + Services.prefs.setBoolPref(PREF_ACTIVE, false); + await Service.engineManager.switchAlternatives(); + Assert.ok( + Service.engineManager.get("passwords") instanceof PasswordEngine, + "turning the pref off switches back to the legacy JS engine" + ); + } finally { + Services.prefs.clearUserPref(PREF_ACTIVE); + await Service.engineManager.switchAlternatives(); + sandbox.restore(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_rust_password_tracker.js b/src/zen/tests/mochitests/sync/test_rust_password_tracker.js new file mode 100644 index 000000000..90a650ad9 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_rust_password_tracker.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Unit tests for RustPasswordTracker.observe(). + * + * observe() is pure scoring logic with no dependency on the Rust store, so it is + * exercised on a minimal fake object rather than a fully initialized engine. + */ + +"use strict"; + +const { RustPasswordEngine } = ChromeUtils.importESModule( + "resource://services-sync/engines/passwords.sys.mjs" +); + +// RustPasswordTracker is not exported directly; reach it through the engine. +const RustPasswordTracker = RustPasswordEngine.prototype._trackerObj; + +function makeTracker({ ignoreAll = false } = {}) { + return { + score: 0, + ignoreAll, + observe: RustPasswordTracker.prototype.observe, + }; +} + +// Stands in for the nsIArray subject passed for "removeAllLogins". +function fakeLoginArray(count) { + const array = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + for (let i = 0; i < count; i++) { + array.appendElement( + Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString) + ); + } + return array; +} + +add_task(async function test_single_change_topics_bump_score() { + for (let data of ["modifyLogin", "addLogin", "removeLogin", "importLogins"]) { + let tracker = makeTracker(); + await tracker.observe(null, "passwordmgr-storage-changed", data); + Assert.equal( + tracker.score, + SCORE_INCREMENT_XLARGE, + `${data} bumps the score by SCORE_INCREMENT_XLARGE` + ); + } +}); + +add_task(async function test_modifyLogin_ignores_syncCounter() { + // The legacy PasswordTracker only scores "modifyLogin" when the subject's + // syncCounter is > 0. The Rust tracker must not look at the subject at all, + // so passing a null subject still bumps the score. + let tracker = makeTracker(); + await tracker.observe(null, "passwordmgr-storage-changed", "modifyLogin"); + Assert.equal( + tracker.score, + SCORE_INCREMENT_XLARGE, + "modifyLogin scores without inspecting the subject's syncCounter" + ); +}); + +add_task(async function test_removeAllLogins_scales_with_count() { + let tracker = makeTracker(); + await tracker.observe( + fakeLoginArray(3), + "passwordmgr-storage-changed", + "removeAllLogins" + ); + Assert.equal( + tracker.score, + SCORE_INCREMENT_XLARGE * 4, + "removeAllLogins bumps the score by SCORE_INCREMENT_XLARGE * (count + 1)" + ); +}); + +add_task(async function test_unknown_topic_does_nothing() { + let tracker = makeTracker(); + await tracker.observe(null, "passwordmgr-storage-changed", "somethingElse"); + Assert.equal(tracker.score, 0, "an unrelated notification leaves the score"); +}); + +add_task(async function test_ignoreAll_suppresses_scoring() { + let tracker = makeTracker({ ignoreAll: true }); + await tracker.observe(null, "passwordmgr-storage-changed", "addLogin"); + await tracker.observe( + fakeLoginArray(5), + "passwordmgr-storage-changed", + "removeAllLogins" + ); + Assert.equal(tracker.score, 0, "ignoreAll suppresses all scoring"); +}); diff --git a/src/zen/tests/mochitests/sync/test_score_triggers.js b/src/zen/tests/mochitests/sync/test_score_triggers.js new file mode 100644 index 000000000..c6afa0640 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_score_triggers.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var upd = collectionsHelper.with_updated_collection; + +function sync_httpd_setup() { + let handlers = {}; + + handlers["/1.1/johndoe/storage/meta/global"] = new ServerWBO( + "global", + {} + ).handler(); + handlers["/1.1/johndoe/storage/steam"] = new ServerWBO("steam", {}).handler(); + + handlers["/1.1/johndoe/info/collections"] = collectionsHelper.handler; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.meta; + + let cr = new ServerWBO("keys"); + handlers["/1.1/johndoe/storage/crypto/keys"] = upd("crypto", cr.handler()); + + let cl = new ServerCollection(); + handlers["/1.1/johndoe/storage/clients"] = upd("clients", cl.handler()); + + return httpd_setup(handlers); +} + +async function setUp(server) { + let engineInfo = await registerRotaryEngine(); + await SyncTestingInfrastructure(server, "johndoe", "ilovejane"); + return engineInfo; +} + +add_task(async function test_tracker_score_updated() { + enableValidationPrefs(); + let { engine, tracker } = await registerRotaryEngine(); + + let scoreUpdated = 0; + + function onScoreUpdated() { + scoreUpdated++; + } + + Svc.Obs.add("weave:engine:score:updated", onScoreUpdated); + + try { + Assert.equal(engine.score, 0); + + tracker.score += SCORE_INCREMENT_SMALL; + Assert.equal(engine.score, SCORE_INCREMENT_SMALL); + + Assert.equal(scoreUpdated, 1); + } finally { + Svc.Obs.remove("weave:engine:score:updated", onScoreUpdated); + tracker.resetScore(); + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_sync_triggered() { + let server = sync_httpd_setup(); + let { engine, tracker } = await setUp(server); + + await Service.login(); + + Service.scheduler.syncThreshold = MULTI_DEVICE_THRESHOLD; + + Assert.equal(Status.login, LOGIN_SUCCEEDED); + tracker.score += SCORE_INCREMENT_XLARGE; + + await promiseOneObserver("weave:service:sync:finish"); + + await Service.startOver(); + await promiseStopServer(server); + + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); +}); + +add_task(async function test_clients_engine_sync_triggered() { + enableValidationPrefs(); + + _("Ensure that client engine score changes trigger a sync."); + + // The clients engine is not registered like other engines. Therefore, + // it needs special treatment throughout the code. Here, we verify the + // global score tracker gives it that treatment. See bug 676042 for more. + + let server = sync_httpd_setup(); + let { engine, tracker } = await setUp(server); + await Service.login(); + + Service.scheduler.syncThreshold = MULTI_DEVICE_THRESHOLD; + Assert.equal(Status.login, LOGIN_SUCCEEDED); + Service.clientsEngine._tracker.score += SCORE_INCREMENT_XLARGE; + + await promiseOneObserver("weave:service:sync:finish"); + _("Sync due to clients engine change completed."); + + await Service.startOver(); + await promiseStopServer(server); + + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); +}); + +add_task(async function test_incorrect_credentials_sync_not_triggered() { + enableValidationPrefs(); + + _( + "Ensure that score changes don't trigger a sync if Status.login != LOGIN_SUCCEEDED." + ); + let server = sync_httpd_setup(); + let { engine, tracker } = await setUp(server); + + // Ensure we don't actually try to sync. + function onSyncStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:sync:start", onSyncStart); + + // Faking incorrect credentials to prevent score update. + Status.login = LOGIN_FAILED_LOGIN_REJECTED; + tracker.score += SCORE_INCREMENT_XLARGE; + + // First wait >100ms (nsITimers can take up to that much time to fire, so + // we can account for the timer in delayedAutoconnect) and then one event + // loop tick (to account for a possible call to weave:service:sync:start). + await promiseNamedTimer(150, {}, "timer"); + await Async.promiseYield(); + + Svc.Obs.remove("weave:service:sync:start", onSyncStart); + + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED); + + await Service.startOver(); + await promiseStopServer(server); + + await tracker.clearChangedIDs(); + await Service.engineManager.unregister(engine); +}); diff --git a/src/zen/tests/mochitests/sync/test_service_attributes.js b/src/zen/tests/mochitests/sync/test_service_attributes.js new file mode 100644 index 000000000..6fab0325f --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_attributes.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { FakeGUIDService } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/fakeservices.sys.mjs" +); + +add_task(async function test_urls() { + _("URL related Service properties correspond to preference settings."); + try { + Assert.equal(Service.clusterURL, ""); + Assert.ok(!Service.userBaseURL); + Assert.equal(Service.infoURL, undefined); + Assert.equal(Service.storageURL, undefined); + Assert.equal(Service.metaURL, undefined); + + _("The 'clusterURL' attribute updates preferences and cached URLs."); + + // Since we don't have a cluster URL yet, these will still not be defined. + Assert.equal(Service.infoURL, undefined); + Assert.ok(!Service.userBaseURL); + Assert.equal(Service.storageURL, undefined); + Assert.equal(Service.metaURL, undefined); + + Service.clusterURL = "http://weave.cluster/1.1/johndoe/"; + + Assert.equal(Service.userBaseURL, "http://weave.cluster/1.1/johndoe/"); + Assert.equal( + Service.infoURL, + "http://weave.cluster/1.1/johndoe/info/collections" + ); + Assert.equal( + Service.storageURL, + "http://weave.cluster/1.1/johndoe/storage/" + ); + Assert.equal( + Service.metaURL, + "http://weave.cluster/1.1/johndoe/storage/meta/global" + ); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_test(function test_syncID() { + _("Service.syncID is auto-generated, corresponds to preference."); + new FakeGUIDService(); + + try { + // Ensure pristine environment + Assert.equal( + Svc.PrefBranch.getPrefType("client.syncID"), + Ci.nsIPrefBranch.PREF_INVALID + ); + + // Performing the first get on the attribute will generate a new GUID. + Assert.equal(Service.syncID, "fake-guid-00"); + Assert.equal(Svc.PrefBranch.getStringPref("client.syncID"), "fake-guid-00"); + + Svc.PrefBranch.setStringPref("client.syncID", Utils.makeGUID()); + Assert.equal(Svc.PrefBranch.getStringPref("client.syncID"), "fake-guid-01"); + Assert.equal(Service.syncID, "fake-guid-01"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + new FakeGUIDService(); + run_next_test(); + } +}); + +add_test(function test_locked() { + _("The 'locked' attribute can be toggled with lock() and unlock()"); + + // Defaults to false + Assert.equal(Service.locked, false); + + Assert.equal(Service.lock(), true); + Assert.equal(Service.locked, true); + + // Locking again will return false + Assert.equal(Service.lock(), false); + + Service.unlock(); + Assert.equal(Service.locked, false); + run_next_test(); +}); + +add_task(async function test_configure_throws_no_user() { + _("configure() throws when no user is signed in"); + const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccounts = getFxAccountsSingleton(); + + const originalGetSignedInUser = fxAccounts.getSignedInUser; + fxAccounts.getSignedInUser = () => Promise.resolve(null); + + try { + await Assert.rejects( + Service.configure(), + /No FxA user is signed in/, + "configure() should throw when no user is signed in" + ); + } finally { + fxAccounts.getSignedInUser = originalGetSignedInUser; + } +}); + +add_task(async function test_configure_throws_without_keys() { + _("configure() throws when user has no sync keys"); + const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccounts = getFxAccountsSingleton(); + + // Mock getSignedInUser to return a user + const originalGetSignedInUser = fxAccounts.getSignedInUser; + fxAccounts.getSignedInUser = () => + Promise.resolve({ email: "test@example.com", uid: "12345" }); + + // Mock hasKeysForScope to return false (no keys) + const originalHasKeysForScope = fxAccounts.keys.hasKeysForScope; + fxAccounts.keys.hasKeysForScope = () => Promise.resolve(false); + + try { + await Assert.rejects( + Service.configure(), + /User does not have sync keys/, + "configure() should throw when no sync keys" + ); + } finally { + fxAccounts.getSignedInUser = originalGetSignedInUser; + fxAccounts.keys.hasKeysForScope = originalHasKeysForScope; + } +}); + +add_task(async function test_configure_succeeds_with_keys() { + _("configure() succeeds when user has sync keys"); + const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccounts = getFxAccountsSingleton(); + + // Mock getSignedInUser to return a user + const originalGetSignedInUser = fxAccounts.getSignedInUser; + fxAccounts.getSignedInUser = () => + Promise.resolve({ email: "test@example.com", uid: "12345" }); + + // Mock hasKeysForScope to return true (has keys) + const originalHasKeysForScope = fxAccounts.keys.hasKeysForScope; + fxAccounts.keys.hasKeysForScope = () => Promise.resolve(true); + + try { + await Service.configure(); + // Should set the username pref + Assert.equal(Svc.PrefBranch.getStringPref("username"), "test@example.com"); + } finally { + fxAccounts.getSignedInUser = originalGetSignedInUser; + fxAccounts.keys.hasKeysForScope = originalHasKeysForScope; + Svc.PrefBranch.clearUserPref("username"); + } +}); + +add_task(async function test_third_party_to_sync_complete_flow() { + _("End-to-end: third-party auth (no keys) -> receive keys -> configure sync"); + const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ); + const fxAccounts = getFxAccountsSingleton(); + + const originalGetSignedInUser = fxAccounts.getSignedInUser; + const originalHasKeysForScope = fxAccounts.keys.hasKeysForScope; + + // 1. User signs in without keys (third-party) + const user = { email: "foo@example.com", uid: "uid12345" }; + fxAccounts.getSignedInUser = () => Promise.resolve(user); + + // 2. Initially no keys - hasKeysForScope returns false + fxAccounts.keys.hasKeysForScope = () => Promise.resolve(false); + + try { + await Assert.rejects( + Service.configure(), + /User does not have sync keys/, + "configure() should throw when no sync keys" + ); + + fxAccounts.keys.hasKeysForScope = () => Promise.resolve(true); + + await Service.configure(); + Assert.equal(Svc.PrefBranch.getStringPref("username"), "foo@example.com"); + } finally { + fxAccounts.getSignedInUser = originalGetSignedInUser; + fxAccounts.keys.hasKeysForScope = originalHasKeysForScope; + Svc.PrefBranch.clearUserPref("username"); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_cluster.js b/src/zen/tests/mochitests/sync/test_service_cluster.js new file mode 100644 index 000000000..b4c14f910 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_cluster.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function test_findCluster() { + syncTestLogging(); + _("Test Service._findCluster()"); + try { + let whenReadyToAuthenticate = Promise.withResolvers(); + Service.identity.whenReadyToAuthenticate = whenReadyToAuthenticate; + whenReadyToAuthenticate.resolve(true); + + Service.identity._ensureValidToken = () => + Promise.reject(new Error("Connection refused")); + + _("_findCluster() throws on network errors (e.g. connection refused)."); + await Assert.rejects(Service.identity._findCluster(), /Connection refused/); + + Service.identity._ensureValidToken = () => + Promise.resolve({ endpoint: "http://weave.user.node" }); + + _("_findCluster() returns the user's cluster node"); + let cluster = await Service.identity._findCluster(); + Assert.equal(cluster, "http://weave.user.node/"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_setCluster() { + syncTestLogging(); + _("Test Service._setCluster()"); + try { + _("Check initial state."); + Assert.equal(Service.clusterURL, ""); + + Service.identity._findCluster = () => "http://weave.user.node/"; + + _("Set the cluster URL."); + Assert.ok(await Service.identity.setCluster()); + Assert.equal(Service.clusterURL, "http://weave.user.node/"); + + _("Setting it again won't make a difference if it's the same one."); + Assert.ok(!(await Service.identity.setCluster())); + Assert.equal(Service.clusterURL, "http://weave.user.node/"); + + _("A 'null' response won't make a difference either."); + Service.identity._findCluster = () => null; + Assert.ok(!(await Service.identity.setCluster())); + Assert.equal(Service.clusterURL, "http://weave.user.node/"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_detect_upgrade.js b/src/zen/tests/mochitests/sync/test_service_detect_upgrade.js new file mode 100644 index 000000000..52aa17a3f --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_detect_upgrade.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoWrapper, WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function v4_upgrade() { + enableValidationPrefs(); + + let clients = new ServerCollection(); + let meta_global = new ServerWBO("global"); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + let keysWBO = new ServerWBO("keys"); + let server = httpd_setup({ + // Special. + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/johndoe/storage/meta/global": upd("meta", meta_global.handler()), + + // Track modified times. + "/1.1/johndoe/storage/clients": upd("clients", clients.handler()), + "/1.1/johndoe/storage/tabs": upd("tabs", new ServerCollection().handler()), + + // Just so we don't get 404s in the logs. + "/1.1/johndoe/storage/bookmarks": new ServerCollection().handler(), + "/1.1/johndoe/storage/forms": new ServerCollection().handler(), + "/1.1/johndoe/storage/history": new ServerCollection().handler(), + "/1.1/johndoe/storage/passwords": new ServerCollection().handler(), + "/1.1/johndoe/storage/prefs": new ServerCollection().handler(), + }); + + try { + Service.status.resetSync(); + + _("Logging in."); + + await configureIdentity({ username: "johndoe" }, server); + + await Service.login(); + Assert.ok(Service.isLoggedIn); + await Service.verifyAndFetchSymmetricKeys(); + Assert.ok(await Service._remoteSetup()); + + async function test_out_of_date() { + _("Old meta/global: " + JSON.stringify(meta_global)); + meta_global.payload = JSON.stringify({ + syncID: "foooooooooooooooooooooooooo", + storageVersion: STORAGE_VERSION + 1, + }); + collections.meta = Date.now() / 1000; + _("New meta/global: " + JSON.stringify(meta_global)); + Service.recordManager.set(Service.metaURL, meta_global); + try { + await Service.sync(); + } catch (ex) {} + Assert.equal(Service.status.sync, VERSION_OUT_OF_DATE); + } + + // See what happens when we bump the storage version. + _("Syncing after server has been upgraded."); + await test_out_of_date(); + + // Same should happen after a wipe. + _("Syncing after server has been upgraded and wiped."); + await Service.wipeServer(); + await test_out_of_date(); + + // Now's a great time to test what happens when keys get replaced. + _("Syncing afresh..."); + Service.logout(); + Service.collectionKeys.clear(); + meta_global.payload = JSON.stringify({ + syncID: "foooooooooooooobbbbbbbbbbbb", + storageVersion: STORAGE_VERSION, + }); + collections.meta = Date.now() / 1000; + Service.recordManager.set(Service.metaURL, meta_global); + await Service.login(); + Assert.ok(Service.isLoggedIn); + await Service.sync(); + Assert.ok(Service.isLoggedIn); + + let serverDecrypted; + let serverKeys; + let serverResp; + + async function retrieve_server_default() { + serverKeys = serverResp = serverDecrypted = null; + + serverKeys = new CryptoWrapper("crypto", "keys"); + serverResp = ( + await serverKeys.fetch(Service.resource(Service.cryptoKeysURL)) + ).response; + Assert.ok(serverResp.success); + + serverDecrypted = await serverKeys.decrypt( + Service.identity.syncKeyBundle + ); + _("Retrieved WBO: " + JSON.stringify(serverDecrypted)); + _("serverKeys: " + JSON.stringify(serverKeys)); + + return serverDecrypted.default; + } + + async function retrieve_and_compare_default(should_succeed) { + let serverDefault = await retrieve_server_default(); + let localDefault = Service.collectionKeys.keyForCollection().keyPairB64; + + _("Retrieved keyBundle: " + JSON.stringify(serverDefault)); + _("Local keyBundle: " + JSON.stringify(localDefault)); + + if (should_succeed) { + Assert.equal( + JSON.stringify(serverDefault), + JSON.stringify(localDefault) + ); + } else { + Assert.notEqual( + JSON.stringify(serverDefault), + JSON.stringify(localDefault) + ); + } + } + + // Uses the objects set above. + async function set_server_keys(pair) { + serverDecrypted.default = pair; + serverKeys.cleartext = serverDecrypted; + await serverKeys.encrypt(Service.identity.syncKeyBundle); + await serverKeys.upload(Service.resource(Service.cryptoKeysURL)); + } + + _("Checking we have the latest keys."); + await retrieve_and_compare_default(true); + + _("Update keys on server."); + await set_server_keys([ + "KaaaaaaaaaaaHAtfmuRY0XEJ7LXfFuqvF7opFdBD/MY=", + "aaaaaaaaaaaapxMO6TEWtLIOv9dj6kBAJdzhWDkkkis=", + ]); + + _("Checking that we no longer have the latest keys."); + await retrieve_and_compare_default(false); + + _("Indeed, they're what we set them to..."); + Assert.equal( + "KaaaaaaaaaaaHAtfmuRY0XEJ7LXfFuqvF7opFdBD/MY=", + (await retrieve_server_default())[0] + ); + + _("Sync. Should download changed keys automatically."); + let oldClientsModified = collections.clients; + let oldTabsModified = collections.tabs; + + await Service.login(); + await Service.sync(); + _("New key should have forced upload of data."); + _("Tabs: " + oldTabsModified + " < " + collections.tabs); + _("Clients: " + oldClientsModified + " < " + collections.clients); + Assert.greater(collections.clients, oldClientsModified); + Assert.greater(collections.tabs, oldTabsModified); + + _("... and keys will now match."); + await retrieve_and_compare_default(true); + + // Clean up. + await Service.startOver(); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); + +add_task(async function v5_upgrade() { + enableValidationPrefs(); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + let keysWBO = new ServerWBO("keys"); + let bulkWBO = new ServerWBO("bulk"); + let clients = new ServerCollection(); + let meta_global = new ServerWBO("global"); + + let server = httpd_setup({ + // Special. + "/1.1/johndoe/storage/meta/global": upd("meta", meta_global.handler()), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/johndoe/storage/crypto/bulk": upd("crypto", bulkWBO.handler()), + + // Track modified times. + "/1.1/johndoe/storage/clients": upd("clients", clients.handler()), + "/1.1/johndoe/storage/tabs": upd("tabs", new ServerCollection().handler()), + }); + + try { + Service.status.resetSync(); + + Service.clusterURL = server.baseURI + "/"; + + await configureIdentity({ username: "johndoe" }, server); + + // Test an upgrade where the contents of the server would cause us to error + // -- keys decrypted with a different sync key, for example. + _("Testing v4 -> v5 (or similar) upgrade."); + async function update_server_keys(syncKeyBundle, wboName, collWBO) { + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", wboName); + await serverKeys.encrypt(syncKeyBundle); + let res = Service.resource(Service.storageURL + collWBO); + Assert.ok((await serverKeys.upload(res)).success); + } + + _("Bumping version."); + // Bump version on the server. + let m = new WBORecord("meta", "global"); + m.payload = { + syncID: "foooooooooooooooooooooooooo", + storageVersion: STORAGE_VERSION + 1, + }; + await m.upload(Service.resource(Service.metaURL)); + + _("New meta/global: " + JSON.stringify(meta_global)); + + // Fill the keys with bad data. + let badKeys = new BulkKeyBundle("crypto"); + await badKeys.generateRandom(); + await update_server_keys(badKeys, "keys", "crypto/keys"); // v4 + await update_server_keys(badKeys, "bulk", "crypto/bulk"); // v5 + + _("Generating new keys."); + await generateNewKeys(Service.collectionKeys); + + // Now sync and see what happens. It should be a version fail, not a crypto + // fail. + + _("Logging in."); + try { + await Service.login(); + } catch (e) { + _("Exception: " + e); + } + _("Status: " + Service.status); + Assert.ok(!Service.isLoggedIn); + Assert.equal(VERSION_OUT_OF_DATE, Service.status.sync); + + // Clean up. + await Service.startOver(); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); + +function run_test() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + run_next_test(); +} diff --git a/src/zen/tests/mochitests/sync/test_service_login.js b/src/zen/tests/mochitests/sync/test_service_login.js new file mode 100644 index 000000000..c75799d38 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_login.js @@ -0,0 +1,224 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + +function login_handling(handler) { + return function (request, response) { + if (has_hawk_header(request)) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.setHeader("Content-Type", "text/plain"); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +add_task(async function test_offline() { + try { + _("The right bits are set when we're offline."); + Services.io.offline = true; + Assert.ok(!(await Service.login())); + Assert.equal(Service.status.login, LOGIN_FAILED_NETWORK_ERROR); + Services.io.offline = false; + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +function setup() { + let janeHelper = track_collections_helper(); + let janeU = janeHelper.with_updated_collection; + let johnHelper = track_collections_helper(); + let johnU = johnHelper.with_updated_collection; + + let server = httpd_setup({ + "/1.1/johndoe/info/collections": login_handling(johnHelper.handler), + "/1.1/janedoe/info/collections": login_handling(janeHelper.handler), + + // We need these handlers because we test login, and login + // is where keys are generated or fetched. + // TODO: have Jane fetch her keys, not generate them... + "/1.1/johndoe/storage/crypto/keys": johnU( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": johnU( + "meta", + new ServerWBO("global").handler() + ), + "/1.1/janedoe/storage/crypto/keys": janeU( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/janedoe/storage/meta/global": janeU( + "meta", + new ServerWBO("global").handler() + ), + }); + + return server; +} + +add_task(async function test_not_logged_in() { + let server = setup(); + try { + await Service.login(); + Assert.ok(!Service.isLoggedIn, "no user configured, so can't be logged in"); + Assert.equal(Service._checkSync(), kSyncNotConfigured); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); + +add_task(async function test_login_logout() { + enableValidationPrefs(); + + let server = setup(); + + try { + _("Force the initial state."); + Service.status.service = STATUS_OK; + Assert.equal(Service.status.service, STATUS_OK); + + _("Try logging in. It won't work because we're not configured yet."); + await Service.login(); + Assert.equal(Service.status.service, CLIENT_NOT_CONFIGURED); + Assert.equal(Service.status.login, LOGIN_FAILED_NO_USERNAME); + Assert.ok(!Service.isLoggedIn); + + _("Try again with a configured account"); + await configureIdentity({ username: "johndoe" }, server); + await Service.login(); + Assert.equal(Service.status.service, STATUS_OK); + Assert.equal(Service.status.login, LOGIN_SUCCEEDED); + Assert.ok(Service.isLoggedIn); + + _("Logout."); + Service.logout(); + Assert.ok(!Service.isLoggedIn); + + _("Logging out again won't do any harm."); + Service.logout(); + Assert.ok(!Service.isLoggedIn); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); + +add_task(async function test_login_on_sync() { + enableValidationPrefs(); + + let server = setup(); + await configureIdentity({ username: "johndoe" }, server); + + try { + _("Sync calls login."); + let oldLogin = Service.login; + let loginCalled = false; + Service.login = async function () { + loginCalled = true; + Service.status.login = LOGIN_SUCCEEDED; + this._loggedIn = false; // So that sync aborts. + return true; + }; + + await Service.sync(); + + Assert.ok(loginCalled); + Service.login = oldLogin; + + // Stub mpLocked. + let mpLocked = true; + Utils.mpLocked = () => mpLocked; + + // Stub scheduleNextSync. This gets called within checkSyncStatus if we're + // ready to sync, so use it as an indicator. + let scheduleNextSyncF = Service.scheduler.scheduleNextSync; + let scheduleCalled = false; + Service.scheduler.scheduleNextSync = function (wait) { + scheduleCalled = true; + scheduleNextSyncF.call(this, wait); + }; + + // Autoconnect still tries to connect in the background (useful behavior: + // for non-MP users and unlocked MPs, this will detect version expiry + // earlier). + // + // Consequently, non-MP users will be logged in as in the pre-Bug 543784 world, + // and checkSyncStatus reflects that by waiting for login. + // + // This process doesn't apply if your MP is still locked, so we make + // checkSyncStatus accept a locked MP in place of being logged in. + // + // This test exercises these two branches. + + _("We're ready to sync if locked."); + Service.enabled = true; + Services.io.offline = false; + Service.scheduler.checkSyncStatus(); + Assert.ok(scheduleCalled); + + _("... and also if we're not locked."); + scheduleCalled = false; + mpLocked = false; + Service.scheduler.checkSyncStatus(); + Assert.ok(scheduleCalled); + Service.scheduler.scheduleNextSync = scheduleNextSyncF; + + // TODO: need better tests around master password prompting. See Bug 620583. + + mpLocked = true; + + // Testing exception handling if master password dialog is canceled. + // Do this by monkeypatching. + Service.identity.unlockAndVerifyAuthState = () => + Promise.resolve(MASTER_PASSWORD_LOCKED); + + let cSTCalled = false; + let lockedSyncCalled = false; + + Service.scheduler.clearSyncTriggers = function () { + cSTCalled = true; + }; + Service._lockedSync = async function () { + lockedSyncCalled = true; + }; + + _("If master password is canceled, login fails and we report lockage."); + Assert.ok(!(await Service.login())); + Assert.equal(Service.status.login, MASTER_PASSWORD_LOCKED); + Assert.equal(Service.status.service, LOGIN_FAILED); + _("Locked? " + Utils.mpLocked()); + _("checkSync reports the correct term."); + Assert.equal(Service._checkSync(), kSyncMasterPasswordLocked); + + _("Sync doesn't proceed and clears triggers if MP is still locked."); + await Service.sync(); + + Assert.ok(cSTCalled); + Assert.ok(!lockedSyncCalled); + + // N.B., a bunch of methods are stubbed at this point. Be careful putting + // new tests after this point! + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_startOver.js b/src/zen/tests/mochitests/sync/test_service_startOver.js new file mode 100644 index 000000000..22d92c76e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_startOver.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function BlaEngine() { + SyncEngine.call(this, "Bla", Service); +} +BlaEngine.prototype = { + removed: false, + async removeClientData() { + this.removed = true; + }, +}; +Object.setPrototypeOf(BlaEngine.prototype, SyncEngine.prototype); + +add_task(async function setup() { + await Service.engineManager.register(BlaEngine); +}); + +add_task(async function test_resetLocalData() { + await configureIdentity(); + Service.status.enforceBackoff = true; + Service.status.backoffInterval = 42; + Service.status.minimumNextSync = 23; + + // Verify set up. + Assert.equal(Service.status.checkSetup(), STATUS_OK); + + // Verify state that the observer sees. + let observerCalled = false; + Svc.Obs.add("weave:service:start-over", function onStartOver() { + Svc.Obs.remove("weave:service:start-over", onStartOver); + observerCalled = true; + + Assert.equal(Service.status.service, CLIENT_NOT_CONFIGURED); + }); + + await Service.startOver(); + Assert.ok(observerCalled); + + // Verify the site was nuked from orbit. + Assert.equal( + Svc.PrefBranch.getPrefType("username"), + Ci.nsIPrefBranch.PREF_INVALID + ); + + Assert.equal(Service.status.service, CLIENT_NOT_CONFIGURED); + Assert.ok(!Service.status.enforceBackoff); + Assert.equal(Service.status.backoffInterval, 0); + Assert.equal(Service.status.minimumNextSync, 0); +}); + +add_task(async function test_removeClientData() { + let engine = Service.engineManager.get("bla"); + + // No cluster URL = no removal. + Assert.ok(!engine.removed); + await Service.startOver(); + Assert.ok(!engine.removed); + + Service.clusterURL = "https://localhost/"; + + Assert.ok(!engine.removed); + await Service.startOver(); + Assert.ok(engine.removed); +}); + +add_task(async function test_reset_SyncScheduler() { + // Some non-default values for SyncScheduler's attributes. + Service.scheduler.idle = true; + Service.scheduler.hasIncomingItems = true; + Svc.PrefBranch.setIntPref("clients.devices.desktop", 42); + Service.scheduler.nextSync = Date.now(); + Service.scheduler.syncThreshold = MULTI_DEVICE_THRESHOLD; + Service.scheduler.syncInterval = Service.scheduler.activeInterval; + + await Service.startOver(); + + Assert.ok(!Service.scheduler.idle); + Assert.ok(!Service.scheduler.hasIncomingItems); + Assert.equal(Service.scheduler.numClients, 0); + Assert.equal(Service.scheduler.nextSync, 0); + Assert.equal(Service.scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + Assert.equal( + Service.scheduler.syncInterval, + Service.scheduler.singleDeviceInterval + ); +}); diff --git a/src/zen/tests/mochitests/sync/test_service_startup.js b/src/zen/tests/mochitests/sync/test_service_startup.js new file mode 100644 index 000000000..66623a951 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_startup.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// Svc.PrefBranch.setStringPref("services.sync.log.appender.dump", "All"); +Svc.PrefBranch.setStringPref("registerEngines", "Tab,Bookmarks,Form,History"); + +add_task(async function run_test() { + validate_all_future_pings(); + _("When imported, Service.onStartup is called"); + + let xps = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + Assert.ok(!xps.enabled); + + // Test fixtures + let { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" + ); + Services.prefs.setStringPref("services.sync.username", "johndoe"); + Assert.ok(xps.enabled); + + _("Service is enabled."); + Assert.equal(Service.enabled, true); + + _("Observers are notified of startup"); + Assert.ok(!Service.status.ready); + Assert.ok(!xps.ready); + + await promiseOneObserver("weave:service:ready"); + + Assert.ok(Service.status.ready); + Assert.ok(xps.ready); + + _("Engines are registered."); + let engines = Service.engineManager.getAll(); + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + // Thunderbird's engines are registered later, so they're not here yet. + Assert.deepEqual( + engines.map(engine => engine.name), + [] + ); + } else { + Assert.deepEqual( + engines.map(engine => engine.name), + ["tabs", "bookmarks", "forms", "history"] + ); + } + + // Clean up. + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + + do_test_finished(); +}); diff --git a/src/zen/tests/mochitests/sync/test_service_sync_401.js b/src/zen/tests/mochitests/sync/test_service_sync_401.js new file mode 100644 index 000000000..0c285872e --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_sync_401.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function login_handling(handler) { + return function (request, response) { + if ( + request.hasHeader("Authorization") && + request.getHeader("Authorization").includes('Hawk id="id"') + ) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +add_task(async function run_test() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + let server = httpd_setup({ + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": upd( + "meta", + new ServerWBO("global").handler() + ), + "/1.1/johndoe/info/collections": login_handling(collectionsHelper.handler), + }); + + const GLOBAL_SCORE = 42; + + try { + _("Set up test fixtures."); + await SyncTestingInfrastructure(server, "johndoe", "ilovejane"); + Service.scheduler.globalScore = GLOBAL_SCORE; + // Avoid daily ping + Svc.PrefBranch.setIntPref("lastPing", Math.floor(Date.now() / 1000)); + + let threw = false; + Svc.Obs.add("weave:service:sync:error", function () { + threw = true; + }); + + _("Initial state: We're successfully logged in."); + await Service.login(); + Assert.ok(Service.isLoggedIn); + Assert.equal(Service.status.login, LOGIN_SUCCEEDED); + + _("Simulate having changed the password somewhere else."); + Service.identity._token.id = "somethingelse"; + Service.identity.unlockAndVerifyAuthState = () => + Promise.resolve(LOGIN_FAILED_LOGIN_REJECTED); + + _("Let's try to sync."); + await Service.sync(); + + _("Verify that sync() threw an exception."); + Assert.ok(threw); + + _("We're no longer logged in."); + Assert.ok(!Service.isLoggedIn); + + _("Sync status won't have changed yet, because we haven't tried again."); + + _("globalScore is reset upon starting a sync."); + Assert.equal(Service.scheduler.globalScore, 0); + + _("Our next sync will fail appropriately."); + try { + await Service.sync(); + } catch (ex) {} + Assert.equal(Service.status.login, LOGIN_FAILED_LOGIN_REJECTED); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_sync_locked.js b/src/zen/tests/mochitests/sync/test_service_sync_locked.js new file mode 100644 index 000000000..5a872e270 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_sync_locked.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function run_test() { + validate_all_future_pings(); + let debug = []; + let info = []; + + function augmentLogger(old) { + let d = old.debug; + let i = old.info; + // For the purposes of this test we don't need to do full formatting + // of the 2nd param, as the ones we care about are always strings. + old.debug = function (m, p) { + debug.push(p ? m + ": " + (p.message || p) : m); + d.call(old, m, p); + }; + old.info = function (m, p) { + info.push(p ? m + ": " + (p.message || p) : m); + i.call(old, m, p); + }; + return old; + } + + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + augmentLogger(Service._log); + + // Avoid daily ping + Svc.PrefBranch.setIntPref("lastPing", Math.floor(Date.now() / 1000)); + + _("Check that sync will log appropriately if already in 'progress'."); + Service._locked = true; + await Service.sync(); + Service._locked = false; + + Assert.ok( + debug[debug.length - 2].startsWith( + 'Exception calling WrappedLock: Could not acquire lock. Label: "service.js: login".' + ) + ); + Assert.equal(info[info.length - 1], "Cannot start sync: already syncing?"); +}); diff --git a/src/zen/tests/mochitests/sync/test_service_sync_remoteSetup.js b/src/zen/tests/mochitests/sync/test_service_sync_remoteSetup.js new file mode 100644 index 000000000..ec95e69c7 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_sync_remoteSetup.js @@ -0,0 +1,241 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +// This sucks, but this test fails if this engine is enabled, due to dumb +// things that aren't related to this engine. In short: +// * Because the addon manager isn't initialized, the addons engine fails to +// initialize. So we end up writing a meta/global with `extension-storage` +// but not addons. +// * After we sync, we discover 'addons' is locally enabled, but because it's +// not in m/g, we decide it's been remotely declined (and it decides this +// without even considering `declined`). So we disable 'addons'. +// * Disabling 'addons' means 'extension-storage' is disabled - but because +// that *is* in meta/global we re-update meta/global to remove it. +// * This test fails due to the extra, unexpected update of m/g. +// +// Another option would be to ensure the addons manager is initialized, but +// that's a larger patch and still isn't strictly relevant to what's being +// tested here, so... +Services.prefs.setBoolPref( + "services.sync.engine.extension-storage.force", + false +); + +add_task(async function run_test() { + enableValidationPrefs(); + + validate_all_future_pings(); + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + let clients = new ServerCollection(); + let meta_global = new ServerWBO("global"); + + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + let collections = collectionsHelper.collections; + + function wasCalledHandler(wbo) { + let handler = wbo.handler(); + return function () { + wbo.wasCalled = true; + handler.apply(this, arguments); + }; + } + + let keysWBO = new ServerWBO("keys"); + let cryptoColl = new ServerCollection({ keys: keysWBO }); + let metaColl = new ServerCollection({ global: meta_global }); + do_test_pending(); + + /** + * Handle the bulk DELETE request sent by wipeServer. + */ + function storageHandler(request, response) { + Assert.equal("DELETE", request.method); + Assert.ok(request.hasHeader("X-Confirm-Delete")); + + _("Wiping out all collections."); + cryptoColl.delete({}); + clients.delete({}); + metaColl.delete({}); + + let ts = new_timestamp(); + collectionsHelper.update_collection("crypto", ts); + collectionsHelper.update_collection("clients", ts); + collectionsHelper.update_collection("meta", ts); + return_timestamp(request, response, ts); + } + + const GLOBAL_PATH = "/1.1/johndoe/storage/meta/global"; + + let handlers = { + "/1.1/johndoe/storage": storageHandler, + "/1.1/johndoe/storage/crypto/keys": upd("crypto", keysWBO.handler()), + "/1.1/johndoe/storage/crypto": upd("crypto", cryptoColl.handler()), + "/1.1/johndoe/storage/clients": upd("clients", clients.handler()), + "/1.1/johndoe/storage/meta": upd("meta", wasCalledHandler(metaColl)), + "/1.1/johndoe/storage/meta/global": upd( + "meta", + wasCalledHandler(meta_global) + ), + "/1.1/johndoe/info/collections": collectionsHelper.handler, + }; + + function mockHandler(path, mock) { + server.registerPathHandler(path, mock(handlers[path])); + return { + restore() { + server.registerPathHandler(path, handlers[path]); + }, + }; + } + + let server = httpd_setup(handlers); + + try { + _("Checking Status.sync with no credentials."); + await Service.verifyAndFetchSymmetricKeys(); + Assert.equal(Service.status.sync, CREDENTIALS_CHANGED); + Assert.equal(Service.status.login, LOGIN_FAILED_NO_PASSPHRASE); + + await configureIdentity({ username: "johndoe" }, server); + + await Service.login(); + _("Checking that remoteSetup returns true when credentials have changed."); + (await Service.recordManager.get(Service.metaURL)).payload.syncID = + "foobar"; + Assert.ok(await Service._remoteSetup()); + + let returnStatusCode = (method, code) => oldMethod => (req, res) => { + if (req.method === method) { + res.setStatusLine(req.httpVersion, code, ""); + } else { + oldMethod(req, res); + } + }; + + let mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 401)); + Service.recordManager.del(Service.metaURL); + _( + "Checking that remoteSetup returns false on 401 on first get /meta/global." + ); + Assert.equal(false, await Service._remoteSetup()); + mock.restore(); + + await Service.login(); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 503)); + Service.recordManager.del(Service.metaURL); + _( + "Checking that remoteSetup returns false on 503 on first get /meta/global." + ); + Assert.equal(false, await Service._remoteSetup()); + Assert.equal(Service.status.sync, METARECORD_DOWNLOAD_FAIL); + mock.restore(); + + await Service.login(); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 404)); + Service.recordManager.del(Service.metaURL); + _("Checking that remoteSetup recovers on 404 on first get /meta/global."); + Assert.ok(await Service._remoteSetup()); + mock.restore(); + + let makeOutdatedMeta = async () => { + Service.metaModified = 0; + let infoResponse = await Service._fetchInfo(); + return { + status: infoResponse.status, + obj: { + crypto: infoResponse.obj.crypto, + clients: infoResponse.obj.clients, + meta: 1, + }, + }; + }; + + _( + "Checking that remoteSetup recovers on 404 on get /meta/global after clear cached one." + ); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 404)); + Service.recordManager.set(Service.metaURL, { isNew: false }); + Assert.ok(await Service._remoteSetup(await makeOutdatedMeta())); + mock.restore(); + + _( + "Checking that remoteSetup returns false on 503 on get /meta/global after clear cached one." + ); + mock = mockHandler(GLOBAL_PATH, returnStatusCode("GET", 503)); + Service.status.sync = ""; + Service.recordManager.set(Service.metaURL, { isNew: false }); + Assert.equal(false, await Service._remoteSetup(await makeOutdatedMeta())); + Assert.equal(Service.status.sync, ""); + mock.restore(); + + metaColl.delete({}); + + _("Do an initial sync."); + await Service.sync(); + + _("Checking that remoteSetup returns true."); + Assert.ok(await Service._remoteSetup()); + + _("Verify that the meta record was uploaded."); + Assert.equal(meta_global.data.syncID, Service.syncID); + Assert.equal(meta_global.data.storageVersion, STORAGE_VERSION); + Assert.equal( + meta_global.data.engines.clients.version, + Service.clientsEngine.version + ); + Assert.equal( + meta_global.data.engines.clients.syncID, + await Service.clientsEngine.getSyncID() + ); + + _( + "Set the collection info hash so that sync() will remember the modified times for future runs." + ); + let lastSync = await Service.clientsEngine.getLastSync(); + collections.meta = lastSync; + collections.clients = lastSync; + await Service.sync(); + + _("Sync again and verify that meta/global wasn't downloaded again"); + meta_global.wasCalled = false; + await Service.sync(); + Assert.ok(!meta_global.wasCalled); + + _( + "Fake modified records. This will cause a redownload, but not reupload since it hasn't changed." + ); + collections.meta += 42; + meta_global.wasCalled = false; + + let metaModified = meta_global.modified; + + await Service.sync(); + Assert.ok(meta_global.wasCalled); + Assert.equal(metaModified, meta_global.modified); + + // Try to screw up HMAC calculation. + // Re-encrypt keys with a new random keybundle, and upload them to the + // server, just as might happen with a second client. + _("Attempting to screw up HMAC by re-encrypting keys."); + let keys = Service.collectionKeys.asWBO(); + let b = new BulkKeyBundle("hmacerror"); + await b.generateRandom(); + collections.crypto = keys.modified = 100 + Date.now() / 1000; // Future modification time. + await keys.encrypt(b); + await keys.upload(Service.resource(Service.cryptoKeysURL)); + + Assert.equal(false, await Service.verifyAndFetchSymmetricKeys()); + Assert.equal(Service.status.login, LOGIN_FAILED_INVALID_PASSPHRASE); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + server.stop(do_test_finished); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_sync_specified.js b/src/zen/tests/mochitests/sync/test_service_sync_specified.js new file mode 100644 index 000000000..845cdb366 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_sync_specified.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +let syncedEngines = []; + +function SteamEngine() { + SyncEngine.call(this, "Steam", Service); +} +SteamEngine.prototype = { + async _sync() { + syncedEngines.push(this.name); + }, +}; +Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype); + +function StirlingEngine() { + SyncEngine.call(this, "Stirling", Service); +} +StirlingEngine.prototype = { + async _sync() { + syncedEngines.push(this.name); + }, +}; +Object.setPrototypeOf(StirlingEngine.prototype, SteamEngine.prototype); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var upd = collectionsHelper.with_updated_collection; + +function sync_httpd_setup(handlers) { + handlers["/1.1/johndoe/info/collections"] = collectionsHelper.handler; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.meta; + + let cr = new ServerWBO("keys"); + handlers["/1.1/johndoe/storage/crypto/keys"] = upd("crypto", cr.handler()); + + let cl = new ServerCollection(); + handlers["/1.1/johndoe/storage/clients"] = upd("clients", cl.handler()); + + return httpd_setup(handlers); +} + +async function setUp() { + syncedEngines = []; + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + engine.syncPriority = 1; + + engine = Service.engineManager.get("stirling"); + engine.enabled = true; + engine.syncPriority = 2; + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": new ServerWBO("global", {}).handler(), + }); + await SyncTestingInfrastructure(server, "johndoe", "ilovejane"); + return server; +} + +add_task(async function setup() { + await Service.engineManager.clear(); + validate_all_future_pings(); + + await Service.engineManager.register(SteamEngine); + await Service.engineManager.register(StirlingEngine); +}); + +add_task(async function test_noEngines() { + enableValidationPrefs(); + + _("Test: An empty array of engines to sync does nothing."); + let server = await setUp(); + + try { + _("Sync with no engines specified."); + await Service.sync({ engines: [] }); + deepEqual(syncedEngines, [], "no engines were synced"); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_oneEngine() { + enableValidationPrefs(); + + _("Test: Only one engine is synced."); + let server = await setUp(); + + try { + _("Sync with 1 engine specified."); + await Service.sync({ engines: ["steam"] }); + deepEqual(syncedEngines, ["steam"]); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_bothEnginesSpecified() { + enableValidationPrefs(); + + _("Test: All engines are synced when specified in the correct order (1)."); + let server = await setUp(); + + try { + _("Sync with both engines specified."); + await Service.sync({ engines: ["steam", "stirling"] }); + deepEqual(syncedEngines, ["steam", "stirling"]); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_bothEnginesSpecified() { + enableValidationPrefs(); + + _("Test: All engines are synced when specified in the correct order (2)."); + let server = await setUp(); + + try { + _("Sync with both engines specified."); + await Service.sync({ engines: ["stirling", "steam"] }); + deepEqual(syncedEngines, ["stirling", "steam"]); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_bothEnginesDefault() { + enableValidationPrefs(); + + _("Test: All engines are synced when nothing is specified."); + let server = await setUp(); + + try { + await Service.sync(); + deepEqual(syncedEngines, ["steam", "stirling"]); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_sync_updateEnabledEngines.js b/src/zen/tests/mochitests/sync/test_service_sync_updateEnabledEngines.js new file mode 100644 index 000000000..fd8b3f71b --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_sync_updateEnabledEngines.js @@ -0,0 +1,587 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const { EngineSynchronizer } = ChromeUtils.importESModule( + "resource://services-sync/stages/enginesync.sys.mjs" +); + +function QuietStore() { + Store.call("Quiet"); +} +QuietStore.prototype = { + async getAllIDs() { + return []; + }, +}; + +function SteamEngine() { + SyncEngine.call(this, "Steam", Service); +} +SteamEngine.prototype = { + // We're not interested in engine sync but what the service does. + _storeObj: QuietStore, + + _sync: async function _sync() { + await this._syncStartup(); + }, +}; +Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype); + +function StirlingEngine() { + SyncEngine.call(this, "Stirling", Service); +} +StirlingEngine.prototype = { + // This engine's enabled state is the same as the SteamEngine's. + get prefName() { + return "steam"; + }, +}; +Object.setPrototypeOf(StirlingEngine.prototype, SteamEngine.prototype); + +// Tracking info/collections. +var collectionsHelper = track_collections_helper(); +var upd = collectionsHelper.with_updated_collection; + +function sync_httpd_setup(handlers) { + handlers["/1.1/johndoe/info/collections"] = collectionsHelper.handler; + delete collectionsHelper.collections.crypto; + delete collectionsHelper.collections.meta; + + let cr = new ServerWBO("keys"); + handlers["/1.1/johndoe/storage/crypto/keys"] = upd("crypto", cr.handler()); + + let cl = new ServerCollection(); + handlers["/1.1/johndoe/storage/clients"] = upd("clients", cl.handler()); + + return httpd_setup(handlers); +} + +async function setUp(server) { + await SyncTestingInfrastructure(server, "johndoe", "ilovejane"); + // Ensure that the server has valid keys so that logging in will work and not + // result in a server wipe, rendering many of these tests useless. + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + let { success } = await serverKeys.upload( + Service.resource(Service.cryptoKeysURL) + ); + ok(success); +} + +const PAYLOAD = 42; + +add_task(async function setup() { + await Service.engineManager.clear(); + validate_all_future_pings(); + + await Service.engineManager.register(SteamEngine); + await Service.engineManager.register(StirlingEngine); +}); + +add_task(async function test_newAccount() { + enableValidationPrefs(); + + _("Test: New account does not disable locally enabled engines."); + let engine = Service.engineManager.get("steam"); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": new ServerWBO("global", {}).handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler(), + }); + await setUp(server); + + try { + _("Engine is enabled from the beginning."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + + _("Sync."); + await Service.sync(); + + _("Engine continues to be enabled."); + Assert.ok(engine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_enabledLocally() { + enableValidationPrefs(); + + _("Test: Engine is disabled on remote clients and enabled locally"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}, + }); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler(), + }); + await setUp(server); + + try { + _("Enable engine locally."); + engine.enabled = true; + + _("Sync."); + await Service.sync(); + + _("Meta record now contains the new engine."); + Assert.ok(!!metaWBO.data.engines.steam); + + _("Engine continues to be enabled."); + Assert.ok(engine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_disabledLocally() { + enableValidationPrefs(); + + _("Test: Engine is enabled on remote clients and disabled locally"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let syncID = await engine.resetLocalSyncID(); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { steam: { syncID, version: engine.version } }, + }); + let steamCollection = new ServerWBO("steam", PAYLOAD); + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": steamCollection.handler(), + }); + await setUp(server); + + try { + _("Disable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + engine.enabled = false; + + _("Sync."); + await Service.sync(); + + _("Meta record no longer contains engine."); + Assert.ok(!metaWBO.data.engines.steam); + + _("Server records are wiped."); + Assert.equal(steamCollection.payload, undefined); + + _("Engine continues to be disabled."); + Assert.ok(!engine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_disabledLocally_wipe503() { + enableValidationPrefs(); + + _("Test: Engine is enabled on remote clients and disabled locally"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let syncID = await engine.resetLocalSyncID(); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { steam: { syncID, version: engine.version } }, + }); + + function service_unavailable(request, response) { + let body = "Service Unavailable"; + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader("Retry-After", "23"); + response.bodyOutputStream.write(body, body.length); + } + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": service_unavailable, + }); + await setUp(server); + + _("Disable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + engine.enabled = false; + + _("Sync."); + await Service.sync(); + Assert.equal(Service.status.sync, SERVER_MAINTENANCE); + + await Service.startOver(); + await promiseStopServer(server); +}); + +add_task(async function test_enabledRemotely() { + enableValidationPrefs(); + + _("Test: Engine is disabled locally and enabled on a remote client"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let syncID = await engine.resetLocalSyncID(); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { steam: { syncID, version: engine.version } }, + }); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": upd("meta", metaWBO.handler()), + + "/1.1/johndoe/storage/steam": upd( + "steam", + new ServerWBO("steam", {}).handler() + ), + }); + await setUp(server); + + // We need to be very careful how we do this, so that we don't trigger a + // fresh start! + try { + _("Upload some keys to avoid a fresh start."); + let wbo = await Service.collectionKeys.generateNewKeysWBO(); + await wbo.encrypt(Service.identity.syncKeyBundle); + Assert.equal( + 200, + (await wbo.upload(Service.resource(Service.cryptoKeysURL))).status + ); + + _("Engine is disabled."); + Assert.ok(!engine.enabled); + + _("Sync."); + await Service.sync(); + + _("Engine is enabled."); + Assert.ok(engine.enabled); + + _("Meta record still present."); + Assert.equal(metaWBO.data.engines.steam.syncID, await engine.getSyncID()); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_disabledRemotelyTwoClients() { + enableValidationPrefs(); + + _( + "Test: Engine is enabled locally and disabled on a remote client... with two clients." + ); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}, + }); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": upd("meta", metaWBO.handler()), + + "/1.1/johndoe/storage/steam": upd( + "steam", + new ServerWBO("steam", {}).handler() + ), + }); + await setUp(server); + + try { + _("Enable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + + _("Sync."); + await Service.sync(); + + _("Disable engine by deleting from meta/global."); + let d = metaWBO.data; + delete d.engines.steam; + metaWBO.payload = JSON.stringify(d); + metaWBO.modified = Date.now() / 1000; + + _("Add a second client and verify that the local pref is changed."); + Service.clientsEngine._store._remoteClients.foobar = { + name: "foobar", + type: "desktop", + }; + await Service.sync(); + + _("Engine is disabled."); + Assert.ok(!engine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_disabledRemotely() { + enableValidationPrefs(); + + _("Test: Engine is enabled locally and disabled on a remote client"); + Service.syncID = "abcdefghij"; + let engine = Service.engineManager.get("steam"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}, + }); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler(), + }); + await setUp(server); + + try { + _("Enable engine locally."); + Service._ignorePrefObserver = true; + engine.enabled = true; + Service._ignorePrefObserver = false; + + _("Sync."); + await Service.sync(); + + _("Engine is not disabled: only one client."); + Assert.ok(engine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_dependentEnginesEnabledLocally() { + enableValidationPrefs(); + + _("Test: Engine is disabled on remote clients and enabled locally"); + Service.syncID = "abcdefghij"; + let steamEngine = Service.engineManager.get("steam"); + let stirlingEngine = Service.engineManager.get("stirling"); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: {}, + }); + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler(), + "/1.1/johndoe/storage/stirling": new ServerWBO("stirling", {}).handler(), + }); + await setUp(server); + + try { + _("Enable engine locally. Doing it on one is enough."); + steamEngine.enabled = true; + + _("Sync."); + await Service.sync(); + + _("Meta record now contains the new engines."); + Assert.ok(!!metaWBO.data.engines.steam); + Assert.ok(!!metaWBO.data.engines.stirling); + + _("Engines continue to be enabled."); + Assert.ok(steamEngine.enabled); + Assert.ok(stirlingEngine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_dependentEnginesDisabledLocally() { + enableValidationPrefs(); + + _( + "Test: Two dependent engines are enabled on remote clients and disabled locally" + ); + Service.syncID = "abcdefghij"; + let steamEngine = Service.engineManager.get("steam"); + let steamSyncID = await steamEngine.resetLocalSyncID(); + let stirlingEngine = Service.engineManager.get("stirling"); + let stirlingSyncID = await stirlingEngine.resetLocalSyncID(); + let metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { + steam: { syncID: steamSyncID, version: steamEngine.version }, + stirling: { syncID: stirlingSyncID, version: stirlingEngine.version }, + }, + }); + + let steamCollection = new ServerWBO("steam", PAYLOAD); + let stirlingCollection = new ServerWBO("stirling", PAYLOAD); + + let server = sync_httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + "/1.1/johndoe/storage/steam": steamCollection.handler(), + "/1.1/johndoe/storage/stirling": stirlingCollection.handler(), + }); + await setUp(server); + + try { + _("Disable engines locally. Doing it on one is enough."); + Service._ignorePrefObserver = true; + steamEngine.enabled = true; + Assert.ok(stirlingEngine.enabled); + Service._ignorePrefObserver = false; + steamEngine.enabled = false; + Assert.ok(!stirlingEngine.enabled); + + _("Sync."); + await Service.sync(); + + _("Meta record no longer contains engines."); + Assert.ok(!metaWBO.data.engines.steam); + Assert.ok(!metaWBO.data.engines.stirling); + + _("Server records are wiped."); + Assert.equal(steamCollection.payload, undefined); + Assert.equal(stirlingCollection.payload, undefined); + + _("Engines continue to be disabled."); + Assert.ok(!steamEngine.enabled); + Assert.ok(!stirlingEngine.enabled); + } finally { + await Service.startOver(); + await promiseStopServer(server); + } +}); + +add_task(async function test_service_updateLocalEnginesState() { + Service.syncID = "abcdefghij"; + const engine = Service.engineManager.get("steam"); + const metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + declined: ["steam"], + engines: {}, + }); + const server = httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + }); + await SyncTestingInfrastructure(server, "johndoe"); + + // Disconnect sync. + await Service.startOver(); + Service._ignorePrefObserver = true; + // Steam engine is enabled on our machine. + engine.enabled = true; + Service._ignorePrefObserver = false; + Service.identity._findCluster = () => server.baseURI + "/1.1/johndoe/"; + + // Update engine state from the server. + await Service.updateLocalEnginesState(); + // Now disabled. + Assert.ok(!engine.enabled); +}); + +add_task(async function test_service_enableAfterUpdateState() { + Service.syncID = "abcdefghij"; + const engine = Service.engineManager.get("steam"); + const metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + declined: ["steam"], + engines: { someengine: {} }, + }); + const server = httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + }); + await SyncTestingInfrastructure(server, "johndoe"); + + // Disconnect sync. + await Service.startOver(); + Service.identity._findCluster = () => server.baseURI + "/1.1/johndoe/"; + + // Update engine state from the server. + await Service.updateLocalEnginesState(); + // Now disabled, reflecting what's on the server. + Assert.ok(!engine.enabled); + // Enable the engine, as though the user selected it via CWTS. + engine.enabled = true; + + // Do the "reconcile local and remote states" dance. + let engineSync = new EngineSynchronizer(Service); + await engineSync._updateEnabledEngines(); + await Service._maybeUpdateDeclined(); + // engine should remain enabled. + Assert.ok(engine.enabled); + // engine should no longer appear in declined on the server. + Assert.deepEqual(metaWBO.data.declined, []); +}); + +add_task(async function test_service_disableAfterUpdateState() { + Service.syncID = "abcdefghij"; + const engine = Service.engineManager.get("steam"); + const metaWBO = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + declined: [], + engines: { steam: {} }, + }); + const server = httpd_setup({ + "/1.1/johndoe/storage/meta/global": metaWBO.handler(), + }); + await SyncTestingInfrastructure(server, "johndoe"); + + // Disconnect sync. + await Service.startOver(); + Service.identity._findCluster = () => server.baseURI + "/1.1/johndoe/"; + + // Update engine state from the server. + await Service.updateLocalEnginesState(); + // Now enabled, reflecting what's on the server. + Assert.ok(engine.enabled); + // Disable the engine, as though via CWTS. + engine.enabled = false; + + // Do the "reconcile local and remote states" dance. + let engineSync = new EngineSynchronizer(Service); + await engineSync._updateEnabledEngines(); + await Service._maybeUpdateDeclined(); + // engine should remain disabled. + Assert.ok(!engine.enabled); + // engine should now appear in declined on the server. + Assert.deepEqual(metaWBO.data.declined, ["steam"]); + // and should have been removed from engines. + Assert.deepEqual(metaWBO.data.engines, {}); +}); + +add_task(async function test_service_updateLocalEnginesState_no_meta_global() { + Service.syncID = "abcdefghij"; + const engine = Service.engineManager.get("steam"); + // The server doesn't contain /meta/global (sync was never enabled). + const server = httpd_setup({}); + await SyncTestingInfrastructure(server, "johndoe"); + + // Disconnect sync. + await Service.startOver(); + Service._ignorePrefObserver = true; + // Steam engine is enabled on our machine. + engine.enabled = true; + Service._ignorePrefObserver = false; + Service.identity._findCluster = () => server.baseURI + "/1.1/johndoe/"; + + // Update engine state from the server. + await Service.updateLocalEnginesState(); + // Still enabled. + Assert.ok(engine.enabled); +}); diff --git a/src/zen/tests/mochitests/sync/test_service_verifyLogin.js b/src/zen/tests/mochitests/sync/test_service_verifyLogin.js new file mode 100644 index 000000000..af1489cca --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_verifyLogin.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function login_handling(handler) { + return function (request, response) { + if (has_hawk_header(request)) { + handler(request, response); + } else { + let body = "Unauthorized"; + response.setStatusLine(request.httpVersion, 401, "Unauthorized"); + response.bodyOutputStream.write(body, body.length); + } + }; +} + +function service_unavailable(request, response) { + let body = "Service Unavailable"; + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader("Retry-After", "42"); + response.bodyOutputStream.write(body, body.length); +} + +function run_test() { + Log.repository.rootLogger.addAppender(new Log.DumpAppender()); + + run_next_test(); +} + +add_task(async function test_verifyLogin() { + // This test expects a clean slate -- no saved passphrase. + await Services.logins.removeAllUserFacingLoginsAsync(); + let johnHelper = track_collections_helper(); + let johnU = johnHelper.with_updated_collection; + + do_test_pending(); + + let server = httpd_setup({ + "/1.1/johndoe/info/collections": login_handling(johnHelper.handler), + "/1.1/janedoe/info/collections": service_unavailable, + + "/1.1/johndoe/storage/crypto/keys": johnU( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": johnU( + "meta", + new ServerWBO("global").handler() + ), + }); + + try { + _("Force the initial state."); + Service.status.service = STATUS_OK; + Assert.equal(Service.status.service, STATUS_OK); + + _("Credentials won't check out because we're not configured yet."); + Service.status.resetSync(); + Assert.equal(false, await Service.verifyLogin()); + Assert.equal(Service.status.service, CLIENT_NOT_CONFIGURED); + Assert.equal(Service.status.login, LOGIN_FAILED_NO_USERNAME); + + _("Success if syncBundleKey is set."); + Service.status.resetSync(); + await configureIdentity({ username: "johndoe" }, server); + Assert.ok(await Service.verifyLogin()); + Assert.equal(Service.status.service, STATUS_OK); + Assert.equal(Service.status.login, LOGIN_SUCCEEDED); + + _( + "If verifyLogin() encounters a server error, it flips on the backoff flag and notifies observers on a 503 with Retry-After." + ); + Service.status.resetSync(); + await configureIdentity({ username: "janedoe" }, server); + Service._updateCachedURLs(); + Assert.ok(!Service.status.enforceBackoff); + let backoffInterval; + Svc.Obs.add("weave:service:backoff:interval", function observe(subject) { + Svc.Obs.remove("weave:service:backoff:interval", observe); + backoffInterval = subject; + }); + Assert.equal(false, await Service.verifyLogin()); + Assert.ok(Service.status.enforceBackoff); + Assert.equal(backoffInterval, 42); + Assert.equal(Service.status.service, LOGIN_FAILED); + Assert.equal(Service.status.login, SERVER_MAINTENANCE); + + _( + "Ensure a network error when finding the cluster sets the right Status bits." + ); + Service.status.resetSync(); + Service.clusterURL = ""; + Service.identity._findCluster = () => "http://localhost:12345/"; + Assert.equal(false, await Service.verifyLogin()); + Assert.equal(Service.status.service, LOGIN_FAILED); + Assert.equal(Service.status.login, LOGIN_FAILED_NETWORK_ERROR); + + _( + "Ensure a network error when getting the collection info sets the right Status bits." + ); + Service.status.resetSync(); + Service.clusterURL = "http://localhost:12345/"; + Assert.equal(false, await Service.verifyLogin()); + Assert.equal(Service.status.service, LOGIN_FAILED); + Assert.equal(Service.status.login, LOGIN_FAILED_NETWORK_ERROR); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + server.stop(do_test_finished); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_service_wipeClient.js b/src/zen/tests/mochitests/sync/test_service_wipeClient.js new file mode 100644 index 000000000..aa48868ca --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_wipeClient.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +function CanDecryptEngine() { + SyncEngine.call(this, "CanDecrypt", Service); +} +CanDecryptEngine.prototype = { + // Override these methods with mocks for the test + async canDecrypt() { + return true; + }, + + wasWiped: false, + async wipeClient() { + this.wasWiped = true; + }, +}; +Object.setPrototypeOf(CanDecryptEngine.prototype, SyncEngine.prototype); + +function CannotDecryptEngine() { + SyncEngine.call(this, "CannotDecrypt", Service); +} +CannotDecryptEngine.prototype = { + // Override these methods with mocks for the test + async canDecrypt() { + return false; + }, + + wasWiped: false, + async wipeClient() { + this.wasWiped = true; + }, +}; +Object.setPrototypeOf(CannotDecryptEngine.prototype, SyncEngine.prototype); + +let canDecryptEngine; +let cannotDecryptEngine; + +add_task(async function setup() { + await Service.engineManager.clear(); + + await Service.engineManager.register(CanDecryptEngine); + await Service.engineManager.register(CannotDecryptEngine); + canDecryptEngine = Service.engineManager.get("candecrypt"); + cannotDecryptEngine = Service.engineManager.get("cannotdecrypt"); +}); + +add_task(async function test_withEngineList() { + try { + _("Ensure initial scenario."); + Assert.ok(!canDecryptEngine.wasWiped); + Assert.ok(!cannotDecryptEngine.wasWiped); + + _("Wipe local engine data."); + await Service.wipeClient(["candecrypt", "cannotdecrypt"]); + + _("Ensure only the engine that can decrypt was wiped."); + Assert.ok(canDecryptEngine.wasWiped); + Assert.ok(!cannotDecryptEngine.wasWiped); + } finally { + canDecryptEngine.wasWiped = false; + cannotDecryptEngine.wasWiped = false; + await Service.startOver(); + } +}); + +add_task(async function test_startOver_clears_keys() { + syncTestLogging(); + await generateNewKeys(Service.collectionKeys); + Assert.ok(!!Service.collectionKeys.keyForCollection()); + await Service.startOver(); + syncTestLogging(); + Assert.ok(!Service.collectionKeys.keyForCollection()); +}); diff --git a/src/zen/tests/mochitests/sync/test_service_wipeServer.js b/src/zen/tests/mochitests/sync/test_service_wipeServer.js new file mode 100644 index 000000000..9fc2592aa --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_service_wipeServer.js @@ -0,0 +1,240 @@ +Svc.PrefBranch.setStringPref("registerEngines", ""); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +// configure the identity we use for this test. +const identityConfig = makeIdentityConfig({ username: "johndoe" }); + +function FakeCollection() { + this.deleted = false; +} +FakeCollection.prototype = { + handler() { + let self = this; + return function (request, response) { + let body = ""; + self.timestamp = new_timestamp(); + let timestamp = "" + self.timestamp; + if (request.method == "DELETE") { + body = timestamp; + self.deleted = true; + } + response.setHeader("X-Weave-Timestamp", timestamp); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.bodyOutputStream.write(body, body.length); + }; + }, +}; + +async function setUpTestFixtures(server) { + Service.clusterURL = server.baseURI + "/"; + + await configureIdentity(identityConfig); +} + +add_task(async function test_wipeServer_list_success() { + _("Service.wipeServer() deletes collections given as argument."); + + let steam_coll = new FakeCollection(); + let diesel_coll = new FakeCollection(); + + let server = httpd_setup({ + "/1.1/johndoe/storage/steam": steam_coll.handler(), + "/1.1/johndoe/storage/diesel": diesel_coll.handler(), + "/1.1/johndoe/storage/petrol": httpd_handler(404, "Not Found"), + }); + + try { + await setUpTestFixtures(server); + await SyncTestingInfrastructure(server, "johndoe", "irrelevant"); + + _("Confirm initial environment."); + Assert.ok(!steam_coll.deleted); + Assert.ok(!diesel_coll.deleted); + + _( + "wipeServer() will happily ignore the non-existent collection and use the timestamp of the last DELETE that was successful." + ); + let timestamp = await Service.wipeServer(["steam", "diesel", "petrol"]); + Assert.equal(timestamp, diesel_coll.timestamp); + + _( + "wipeServer stopped deleting after encountering an error with the 'petrol' collection, thus only 'steam' has been deleted." + ); + Assert.ok(steam_coll.deleted); + Assert.ok(diesel_coll.deleted); + } finally { + await promiseStopServer(server); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_wipeServer_list_503() { + _("Service.wipeServer() deletes collections given as argument."); + + let steam_coll = new FakeCollection(); + let diesel_coll = new FakeCollection(); + + let server = httpd_setup({ + "/1.1/johndoe/storage/steam": steam_coll.handler(), + "/1.1/johndoe/storage/petrol": httpd_handler(503, "Service Unavailable"), + "/1.1/johndoe/storage/diesel": diesel_coll.handler(), + }); + + try { + await setUpTestFixtures(server); + await SyncTestingInfrastructure(server, "johndoe", "irrelevant"); + + _("Confirm initial environment."); + Assert.ok(!steam_coll.deleted); + Assert.ok(!diesel_coll.deleted); + + _( + "wipeServer() will happily ignore the non-existent collection, delete the 'steam' collection and abort after an receiving an error on the 'petrol' collection." + ); + let error; + try { + await Service.wipeServer(["non-existent", "steam", "petrol", "diesel"]); + do_throw("Should have thrown!"); + } catch (ex) { + error = ex; + } + _("wipeServer() threw this exception: " + error); + Assert.equal(error.status, 503); + + _( + "wipeServer stopped deleting after encountering an error with the 'petrol' collection, thus only 'steam' has been deleted." + ); + Assert.ok(steam_coll.deleted); + Assert.ok(!diesel_coll.deleted); + } finally { + await promiseStopServer(server); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_wipeServer_all_success() { + _("Service.wipeServer() deletes all the things."); + + /** + * Handle the bulk DELETE request sent by wipeServer. + */ + let deleted = false; + let serverTimestamp; + function storageHandler(request, response) { + Assert.equal("DELETE", request.method); + Assert.ok(request.hasHeader("X-Confirm-Delete")); + deleted = true; + serverTimestamp = return_timestamp(request, response); + } + + let server = httpd_setup({ + "/1.1/johndoe/storage": storageHandler, + }); + await setUpTestFixtures(server); + + _("Try deletion."); + await SyncTestingInfrastructure(server, "johndoe", "irrelevant"); + let returnedTimestamp = await Service.wipeServer(); + Assert.ok(deleted); + Assert.equal(returnedTimestamp, serverTimestamp); + + await promiseStopServer(server); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } +}); + +add_task(async function test_wipeServer_all_404() { + _("Service.wipeServer() accepts a 404."); + + /** + * Handle the bulk DELETE request sent by wipeServer. Returns a 404. + */ + let deleted = false; + let serverTimestamp; + function storageHandler(request, response) { + Assert.equal("DELETE", request.method); + Assert.ok(request.hasHeader("X-Confirm-Delete")); + deleted = true; + serverTimestamp = new_timestamp(); + response.setHeader("X-Weave-Timestamp", "" + serverTimestamp); + response.setStatusLine(request.httpVersion, 404, "Not Found"); + } + + let server = httpd_setup({ + "/1.1/johndoe/storage": storageHandler, + }); + await setUpTestFixtures(server); + + _("Try deletion."); + await SyncTestingInfrastructure(server, "johndoe", "irrelevant"); + let returnedTimestamp = await Service.wipeServer(); + Assert.ok(deleted); + Assert.equal(returnedTimestamp, serverTimestamp); + + await promiseStopServer(server); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } +}); + +add_task(async function test_wipeServer_all_503() { + _("Service.wipeServer() throws if it encounters a non-200/404 response."); + + /** + * Handle the bulk DELETE request sent by wipeServer. Returns a 503. + */ + function storageHandler(request, response) { + Assert.equal("DELETE", request.method); + Assert.ok(request.hasHeader("X-Confirm-Delete")); + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + } + + let server = httpd_setup({ + "/1.1/johndoe/storage": storageHandler, + }); + await setUpTestFixtures(server); + + _("Try deletion."); + let error; + try { + await SyncTestingInfrastructure(server, "johndoe", "irrelevant"); + await Service.wipeServer(); + do_throw("Should have thrown!"); + } catch (ex) { + error = ex; + } + Assert.equal(error.status, 503); + + await promiseStopServer(server); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } +}); + +add_task(async function test_wipeServer_all_connectionRefused() { + _("Service.wipeServer() throws if it encounters a network problem."); + let server = httpd_setup({}); + await setUpTestFixtures(server); + + Service.clusterURL = "http://localhost:4352/"; + + _("Try deletion."); + try { + await Service.wipeServer(); + do_throw("Should have thrown!"); + } catch (ex) { + Assert.equal(ex.result, Cr.NS_ERROR_CONNECTION_REFUSED); + } + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_status.js b/src/zen/tests/mochitests/sync/test_status.js new file mode 100644 index 000000000..5bcfa182c --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_status.js @@ -0,0 +1,83 @@ +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); + +function run_test() { + // Check initial states + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.backoffInterval, 0); + Assert.equal(Status.minimumNextSync, 0); + + Assert.equal(Status.service, STATUS_OK); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.equal(Status.login, LOGIN_SUCCEEDED); + if (Status.engines.length) { + do_throw("Status.engines should be empty."); + } + Assert.equal(Status.partial, false); + + // Check login status + for (let code of [LOGIN_FAILED_NO_USERNAME, LOGIN_FAILED_NO_PASSPHRASE]) { + Status.login = code; + Assert.equal(Status.login, code); + Assert.equal(Status.service, CLIENT_NOT_CONFIGURED); + Status.resetSync(); + } + + Status.login = LOGIN_FAILED; + Assert.equal(Status.login, LOGIN_FAILED); + Assert.equal(Status.service, LOGIN_FAILED); + Status.resetSync(); + + Status.login = LOGIN_SUCCEEDED; + Assert.equal(Status.login, LOGIN_SUCCEEDED); + Assert.equal(Status.service, STATUS_OK); + Status.resetSync(); + + // Check sync status + Status.sync = SYNC_FAILED; + Assert.equal(Status.sync, SYNC_FAILED); + Assert.equal(Status.service, SYNC_FAILED); + + Status.sync = SYNC_SUCCEEDED; + Assert.equal(Status.sync, SYNC_SUCCEEDED); + Assert.equal(Status.service, STATUS_OK); + + Status.resetSync(); + + // Check engine status + Status.engines = ["testEng1", ENGINE_SUCCEEDED]; + Assert.equal(Status.engines.testEng1, ENGINE_SUCCEEDED); + Assert.equal(Status.service, STATUS_OK); + + Status.engines = ["testEng2", ENGINE_DOWNLOAD_FAIL]; + Assert.equal(Status.engines.testEng1, ENGINE_SUCCEEDED); + Assert.equal(Status.engines.testEng2, ENGINE_DOWNLOAD_FAIL); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + + Status.engines = ["testEng3", ENGINE_SUCCEEDED]; + Assert.equal(Status.engines.testEng1, ENGINE_SUCCEEDED); + Assert.equal(Status.engines.testEng2, ENGINE_DOWNLOAD_FAIL); + Assert.equal(Status.engines.testEng3, ENGINE_SUCCEEDED); + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + + // Check resetSync + Status.sync = SYNC_FAILED; + Status.resetSync(); + + Assert.equal(Status.service, STATUS_OK); + Assert.equal(Status.sync, SYNC_SUCCEEDED); + if (Status.engines.length) { + do_throw("Status.engines should be empty."); + } + + // Check resetBackoff + Status.enforceBackoff = true; + Status.backOffInterval = 4815162342; + Status.backOffInterval = 42; + Status.resetBackoff(); + + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.backoffInterval, 0); + Assert.equal(Status.minimumNextSync, 0); +} diff --git a/src/zen/tests/mochitests/sync/test_status_checkSetup.js b/src/zen/tests/mochitests/sync/test_status_checkSetup.js new file mode 100644 index 000000000..fe3e7cad8 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_status_checkSetup.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); + +add_task(async function test_status_checkSetup() { + try { + _("Fresh setup, we're not configured."); + Assert.equal(Status.checkSetup(), CLIENT_NOT_CONFIGURED); + Assert.equal(Status.login, LOGIN_FAILED_NO_USERNAME); + Status.resetSync(); + + _("Let's provide the syncKeyBundle"); + await configureIdentity(); + + _("checkSetup()"); + Assert.equal(Status.checkSetup(), STATUS_OK); + Status.resetSync(); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); diff --git a/src/zen/tests/mochitests/sync/test_sync_auth_manager.js b/src/zen/tests/mochitests/sync/test_sync_auth_manager.js new file mode 100644 index 000000000..2e69ae6b5 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_sync_auth_manager.js @@ -0,0 +1,810 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { AuthenticationError, SyncAuthManager } = ChromeUtils.importESModule( + "resource://services-sync/sync_auth.sys.mjs" +); +const { Resource } = ChromeUtils.importESModule( + "resource://services-sync/resource.sys.mjs" +); +const { initializeIdentityWithTokenServerResponse } = + ChromeUtils.importESModule( + "resource://testing-common/services/sync/fxa_utils.sys.mjs" + ); +const { HawkClient } = ChromeUtils.importESModule( + "resource://services-common/hawkclient.sys.mjs" +); +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FxAccountsClient } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); +const { ERRNO_INVALID_AUTH_TOKEN, SCOPE_APP_SYNC } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsCommon.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); +const { TokenServerClient, TokenServerClientServerError } = + ChromeUtils.importESModule( + "resource://services-common/tokenserverclient.sys.mjs" + ); +const { AccountState, ERROR_INVALID_ACCOUNT_STATE } = + ChromeUtils.importESModule("resource://gre/modules/FxAccounts.sys.mjs"); + +const SECOND_MS = 1000; +const MINUTE_MS = SECOND_MS * 60; +const HOUR_MS = MINUTE_MS * 60; + +const MOCK_ACCESS_TOKEN = + "e3c5caf17f27a0d9e351926a928938b3737df43e91d4992a5a5fca9a7bdef8ba"; + +var globalIdentityConfig = makeIdentityConfig(); +var globalSyncAuthManager = new SyncAuthManager(); +configureFxAccountIdentity(globalSyncAuthManager, globalIdentityConfig); + +/** + * Mock client clock and skew vs server in FxAccounts signed-in user module and + * API client. sync_auth.js queries these values to construct HAWK + * headers. We will use this to test clock skew compensation in these headers + * below. + */ +var MockFxAccountsClient = function () { + FxAccountsClient.apply(this); +}; +MockFxAccountsClient.prototype = { + accountStatus() { + return Promise.resolve(true); + }, + getScopedKeyData() { + return Promise.resolve({ + [SCOPE_APP_SYNC]: { + identifier: SCOPE_APP_SYNC, + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1234567890123, + }, + }); + }, +}; +Object.setPrototypeOf( + MockFxAccountsClient.prototype, + FxAccountsClient.prototype +); + +add_test(function test_initial_state() { + _("Verify initial state"); + Assert.ok(!globalSyncAuthManager._token); + Assert.ok(!globalSyncAuthManager._hasValidToken()); + run_next_test(); +}); + +add_task(async function test_initialialize() { + _("Verify start after fetching token"); + await globalSyncAuthManager._ensureValidToken(); + Assert.ok(!!globalSyncAuthManager._token); + Assert.ok(globalSyncAuthManager._hasValidToken()); +}); + +add_task(async function test_refreshOAuthTokenOn401() { + _("Refreshes the FXA OAuth token after a 401."); + let getTokenCount = 0; + let syncAuthManager = new SyncAuthManager(); + let identityConfig = makeIdentityConfig(); + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + configureFxAccountIdentity(syncAuthManager, identityConfig, fxaInternal); + syncAuthManager._fxaService._internal.initialize(); + syncAuthManager._fxaService.getOAuthToken = () => { + ++getTokenCount; + return Promise.resolve(MOCK_ACCESS_TOKEN); + }; + + let didReturn401 = false; + let didReturn200 = false; + let mockTSC = mockTokenServer(() => { + if (getTokenCount <= 1) { + didReturn401 = true; + return { + status: 401, + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }; + } + didReturn200 = true; + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "id", + key: "key", + api_endpoint: "http://example.com/", + uid: "uid", + duration: 300, + }), + }; + }); + + syncAuthManager._tokenServerClient = mockTSC; + + await syncAuthManager._ensureValidToken(); + + Assert.equal(getTokenCount, 2); + Assert.ok(didReturn401); + Assert.ok(didReturn200); + Assert.ok(syncAuthManager._token); + Assert.ok(syncAuthManager._hasValidToken()); +}); + +add_task(async function test_initialializeWithAuthErrorAndDeletedAccount() { + _("Verify sync state with auth error + account deleted"); + + var identityConfig = makeIdentityConfig(); + var syncAuthManager = new SyncAuthManager(); + + // Use the real `getOAuthToken` method that calls + // `mockFxAClient.accessTokenWithSessionToken`. + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + delete fxaInternal.getOAuthToken; + + configureFxAccountIdentity(syncAuthManager, identityConfig, fxaInternal); + syncAuthManager._fxaService._internal.initialize(); + + let accessTokenWithSessionTokenCalled = false; + let accountStatusCalled = false; + let sessionStatusCalled = false; + + let AuthErrorMockFxAClient = function () { + FxAccountsClient.apply(this); + }; + AuthErrorMockFxAClient.prototype = { + accessTokenWithSessionToken() { + accessTokenWithSessionTokenCalled = true; + return Promise.reject({ + code: 401, + errno: ERRNO_INVALID_AUTH_TOKEN, + }); + }, + accountStatus() { + accountStatusCalled = true; + return Promise.resolve(false); + }, + sessionStatus() { + sessionStatusCalled = true; + return Promise.resolve(false); + }, + }; + Object.setPrototypeOf( + AuthErrorMockFxAClient.prototype, + FxAccountsClient.prototype + ); + + let mockFxAClient = new AuthErrorMockFxAClient(); + syncAuthManager._fxaService._internal._fxAccountsClient = mockFxAClient; + + await Assert.rejects( + syncAuthManager._ensureValidToken(), + err => { + Assert.equal(err.message, ERROR_INVALID_ACCOUNT_STATE); + return true; // expected error + }, + "should reject because the account was deleted" + ); + + Assert.ok(accessTokenWithSessionTokenCalled); + Assert.ok(sessionStatusCalled); + Assert.ok(accountStatusCalled); + Assert.ok(!syncAuthManager._token); + Assert.ok(!syncAuthManager._hasValidToken()); +}); + +add_task(async function test_getResourceAuthenticator() { + _( + "SyncAuthManager supplies a Resource Authenticator callback which returns a Hawk header." + ); + configureFxAccountIdentity(globalSyncAuthManager); + let authenticator = globalSyncAuthManager.getResourceAuthenticator(); + Assert.ok(!!authenticator); + let req = { + uri: CommonUtils.makeURI("https://example.net/somewhere/over/the/rainbow"), + method: "GET", + }; + let output = await authenticator(req, "GET"); + Assert.ok("headers" in output); + Assert.ok("authorization" in output.headers); + Assert.ok(output.headers.authorization.startsWith("Hawk")); + _("Expected internal state after successful call."); + Assert.equal( + globalSyncAuthManager._token.uid, + globalIdentityConfig.fxaccount.token.uid + ); +}); + +add_task(async function test_resourceAuthenticatorSkew() { + _( + "SyncAuthManager Resource Authenticator compensates for clock skew in Hawk header." + ); + + // Clock is skewed 12 hours into the future + // We pick a date in the past so we don't risk concealing bugs in code that + // uses new Date() instead of our given date. + let now = + new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS; + let syncAuthManager = new SyncAuthManager(); + let hawkClient = new HawkClient("https://example.net/v1", "/foo"); + + // mock fxa hawk client skew + hawkClient.now = function () { + dump("mocked client now: " + now + "\n"); + return now; + }; + // Imagine there's already been one fxa request and the hawk client has + // already detected skew vs the fxa auth server. + let localtimeOffsetMsec = -1 * 12 * HOUR_MS; + hawkClient._localtimeOffsetMsec = localtimeOffsetMsec; + + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = hawkClient; + + // Sanity check + Assert.equal(hawkClient.now(), now); + Assert.equal(hawkClient.localtimeOffsetMsec, localtimeOffsetMsec); + + // Properly picked up by the client + Assert.equal(fxaClient.now(), now); + Assert.equal(fxaClient.localtimeOffsetMsec, localtimeOffsetMsec); + + let identityConfig = makeIdentityConfig(); + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + fxaInternal._now_is = now; + fxaInternal.fxAccountsClient = fxaClient; + + // Mocks within mocks... + configureFxAccountIdentity( + syncAuthManager, + globalIdentityConfig, + fxaInternal + ); + + Assert.equal(syncAuthManager._fxaService._internal.now(), now); + Assert.equal( + syncAuthManager._fxaService._internal.localtimeOffsetMsec, + localtimeOffsetMsec + ); + + Assert.equal(syncAuthManager._fxaService._internal.now(), now); + Assert.equal( + syncAuthManager._fxaService._internal.localtimeOffsetMsec, + localtimeOffsetMsec + ); + + let request = new Resource("https://example.net/i/like/pie/"); + let authenticator = syncAuthManager.getResourceAuthenticator(); + let output = await authenticator(request, "GET"); + dump("output" + JSON.stringify(output)); + let authHeader = output.headers.authorization; + Assert.ok(authHeader.startsWith("Hawk")); + + // Skew correction is applied in the header and we're within the two-minute + // window. + Assert.equal(getTimestamp(authHeader), now - 12 * HOUR_MS); + Assert.less(getTimestampDelta(authHeader, now) - 12 * HOUR_MS, 2 * MINUTE_MS); +}); + +add_task(async function test_RESTResourceAuthenticatorSkew() { + _( + "SyncAuthManager REST Resource Authenticator compensates for clock skew in Hawk header." + ); + + // Clock is skewed 12 hours into the future from our arbitary date + let now = + new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS; + let syncAuthManager = new SyncAuthManager(); + let hawkClient = new HawkClient("https://example.net/v1", "/foo"); + + // mock fxa hawk client skew + hawkClient.now = function () { + return now; + }; + // Imagine there's already been one fxa request and the hawk client has + // already detected skew vs the fxa auth server. + hawkClient._localtimeOffsetMsec = -1 * 12 * HOUR_MS; + + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = hawkClient; + + let identityConfig = makeIdentityConfig(); + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + fxaInternal._now_is = now; + fxaInternal.fxAccountsClient = fxaClient; + + configureFxAccountIdentity( + syncAuthManager, + globalIdentityConfig, + fxaInternal + ); + + Assert.equal(syncAuthManager._fxaService._internal.now(), now); + + let request = new Resource("https://example.net/i/like/pie/"); + let authenticator = syncAuthManager.getResourceAuthenticator(); + let output = await authenticator(request, "GET"); + dump("output" + JSON.stringify(output)); + let authHeader = output.headers.authorization; + Assert.ok(authHeader.startsWith("Hawk")); + + // Skew correction is applied in the header and we're within the two-minute + // window. + Assert.equal(getTimestamp(authHeader), now - 12 * HOUR_MS); + Assert.less(getTimestampDelta(authHeader, now) - 12 * HOUR_MS, 2 * MINUTE_MS); +}); + +add_task(async function test_ensureLoggedIn() { + configureFxAccountIdentity(globalSyncAuthManager); + await globalSyncAuthManager._ensureValidToken(); + Assert.equal(Status.login, LOGIN_SUCCEEDED, "original initialize worked"); + Assert.ok(globalSyncAuthManager._token); + + // arrange for no logged in user. + let fxa = globalSyncAuthManager._fxaService; + let signedInUser = + fxa._internal.currentAccountState.storageManager.accountData; + fxa._internal.currentAccountState.storageManager.accountData = null; + await Assert.rejects( + globalSyncAuthManager._ensureValidToken(true), + /no user is logged in/, + "expecting rejection due to no user" + ); + // Restore the logged in user to what it was. + fxa._internal.currentAccountState.storageManager.accountData = signedInUser; + Status.login = LOGIN_FAILED_LOGIN_REJECTED; + await globalSyncAuthManager._ensureValidToken(true); + Assert.equal(Status.login, LOGIN_SUCCEEDED, "final ensureLoggedIn worked"); +}); + +add_task(async function test_tokenExpiration() { + _("SyncAuthManager notices token expiration:"); + let bimExp = new SyncAuthManager(); + configureFxAccountIdentity(bimExp, globalIdentityConfig); + + let authenticator = bimExp.getResourceAuthenticator(); + Assert.ok(!!authenticator); + let req = { + uri: CommonUtils.makeURI("https://example.net/somewhere/over/the/rainbow"), + method: "GET", + }; + await authenticator(req, "GET"); + + // Mock the clock. + _("Forcing the token to expire ..."); + Object.defineProperty(bimExp, "_now", { + value: function customNow() { + return Date.now() + 3000001; + }, + writable: true, + }); + Assert.less(bimExp._token.expiration, bimExp._now()); + _("... means SyncAuthManager knows to re-fetch it on the next call."); + Assert.ok(!bimExp._hasValidToken()); +}); + +add_task(async function test_getTokenErrors() { + _("SyncAuthManager correctly handles various failures to get a token."); + + _("Arrange for a 401 - Sync should reflect an auth error."); + initializeIdentityWithTokenServerResponse({ + status: 401, + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + let syncAuthManager = Service.identity; + + await Assert.rejects( + syncAuthManager._ensureValidToken(), + AuthenticationError, + "should reject due to 401" + ); + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected"); + + // XXX - other interesting responses to return? + + // And for good measure, some totally "unexpected" errors - we generally + // assume these problems are going to magically go away at some point. + _( + "Arrange for an empty body with a 200 response - should reflect a network error." + ); + initializeIdentityWithTokenServerResponse({ + status: 200, + headers: [], + body: "", + }); + syncAuthManager = Service.identity; + await Assert.rejects( + syncAuthManager._ensureValidToken(), + TokenServerClientServerError, + "should reject due to non-JSON response" + ); + Assert.equal( + Status.login, + LOGIN_FAILED_NETWORK_ERROR, + "login state is LOGIN_FAILED_NETWORK_ERROR" + ); +}); + +add_task(async function test_refreshAccessTokenOn401() { + _("SyncAuthManager refreshes the FXA OAuth access token after a 401."); + var identityConfig = makeIdentityConfig(); + var syncAuthManager = new SyncAuthManager(); + // Use the real `getOAuthToken` method that calls + // `mockFxAClient.accessTokenWithSessionToken`. + let fxaInternal = makeFxAccountsInternalMock(identityConfig); + delete fxaInternal.getOAuthToken; + configureFxAccountIdentity(syncAuthManager, identityConfig, fxaInternal); + syncAuthManager._fxaService._internal.initialize(); + + let getTokenCount = 0; + + let CheckSignMockFxAClient = function () { + FxAccountsClient.apply(this); + }; + CheckSignMockFxAClient.prototype = { + accessTokenWithSessionToken() { + ++getTokenCount; + return Promise.resolve({ access_token: "token" }); + }, + }; + Object.setPrototypeOf( + CheckSignMockFxAClient.prototype, + FxAccountsClient.prototype + ); + + let mockFxAClient = new CheckSignMockFxAClient(); + syncAuthManager._fxaService._internal._fxAccountsClient = mockFxAClient; + + let didReturn401 = false; + let didReturn200 = false; + let mockTSC = mockTokenServer(() => { + if (getTokenCount <= 1) { + didReturn401 = true; + return { + status: 401, + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }; + } + didReturn200 = true; + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "id", + key: "key", + api_endpoint: "http://example.com/", + uid: "uid", + duration: 300, + }), + }; + }); + + syncAuthManager._tokenServerClient = mockTSC; + + await syncAuthManager._ensureValidToken(); + + Assert.equal(getTokenCount, 2); + Assert.ok(didReturn401); + Assert.ok(didReturn200); + Assert.ok(syncAuthManager._token); + Assert.ok(syncAuthManager._hasValidToken()); +}); + +add_task(async function test_getTokenErrorWithRetry() { + _("tokenserver sends an observer notification on various backoff headers."); + + // Set Sync's backoffInterval to zero - after we simulated the backoff header + // it should reflect the value we sent. + Status.backoffInterval = 0; + _("Arrange for a 503 with a Retry-After header."); + initializeIdentityWithTokenServerResponse({ + status: 503, + headers: { "content-type": "application/json", "retry-after": "100" }, + body: JSON.stringify({}), + }); + let syncAuthManager = Service.identity; + + await Assert.rejects( + syncAuthManager._ensureValidToken(), + TokenServerClientServerError, + "should reject due to 503" + ); + + // The observer should have fired - check it got the value in the response. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected"); + // Sync will have the value in ms with some slop - so check it is at least that. + Assert.greaterOrEqual(Status.backoffInterval, 100000); + + _("Arrange for a 200 with an X-Backoff header."); + Status.backoffInterval = 0; + initializeIdentityWithTokenServerResponse({ + status: 503, + headers: { "content-type": "application/json", "x-backoff": "200" }, + body: JSON.stringify({}), + }); + syncAuthManager = Service.identity; + + await Assert.rejects( + syncAuthManager._ensureValidToken(), + TokenServerClientServerError, + "should reject due to no token in response" + ); + + // The observer should have fired - check it got the value in the response. + Assert.greaterOrEqual(Status.backoffInterval, 200000); +}); + +add_task(async function test_getKeysErrorWithBackoff() { + _( + "Auth server (via hawk) sends an observer notification on backoff headers." + ); + + // Set Sync's backoffInterval to zero - after we simulated the backoff header + // it should reflect the value we sent. + Status.backoffInterval = 0; + _("Arrange for a 503 with a X-Backoff header."); + + let config = makeIdentityConfig(); + // We want no scopedKeys so we attempt to fetch them. + delete config.fxaccount.user.scopedKeys; + config.fxaccount.user.keyFetchToken = "keyfetchtoken"; + await initializeIdentityWithHAWKResponseFactory( + config, + function (method, data, uri) { + Assert.equal(method, "get"); + Assert.equal(uri, "http://mockedserver:9999/account/keys"); + return { + status: 503, + headers: { "content-type": "application/json", "x-backoff": "100" }, + body: "{}", + }; + } + ); + + let syncAuthManager = Service.identity; + await Assert.rejects( + syncAuthManager._ensureValidToken(), + TokenServerClientServerError, + "should reject due to 503" + ); + + // The observer should have fired - check it got the value in the response. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected"); + // Sync will have the value in ms with some slop - so check it is at least that. + Assert.greaterOrEqual(Status.backoffInterval, 100000); +}); + +add_task(async function test_getKeysErrorWithRetry() { + _("Auth server (via hawk) sends an observer notification on retry headers."); + + // Set Sync's backoffInterval to zero - after we simulated the backoff header + // it should reflect the value we sent. + Status.backoffInterval = 0; + _("Arrange for a 503 with a Retry-After header."); + + let config = makeIdentityConfig(); + // We want no scopedKeys so we attempt to fetch them. + delete config.fxaccount.user.scopedKeys; + config.fxaccount.user.keyFetchToken = "keyfetchtoken"; + await initializeIdentityWithHAWKResponseFactory( + config, + function (method, data, uri) { + Assert.equal(method, "get"); + Assert.equal(uri, "http://mockedserver:9999/account/keys"); + return { + status: 503, + headers: { "content-type": "application/json", "retry-after": "100" }, + body: "{}", + }; + } + ); + + let syncAuthManager = Service.identity; + await Assert.rejects( + syncAuthManager._ensureValidToken(), + TokenServerClientServerError, + "should reject due to 503" + ); + + // The observer should have fired - check it got the value in the response. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login was rejected"); + // Sync will have the value in ms with some slop - so check it is at least that. + Assert.greaterOrEqual(Status.backoffInterval, 100000); +}); + +add_task(async function test_getHAWKErrors() { + _("SyncAuthManager correctly handles various HAWK failures."); + + _("Arrange for a 401 - Sync should reflect an auth error."); + let config = makeIdentityConfig(); + await initializeIdentityWithHAWKResponseFactory( + config, + function (method, data, uri) { + if (uri == "http://mockedserver:9999/oauth/token") { + Assert.equal(method, "post"); + return { + status: 401, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: 401, + errno: 110, + error: "invalid token", + }), + }; + } + // For any follow-up requests that check account status. + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }; + } + ); + Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected"); + + // XXX - other interesting responses to return? + + // And for good measure, some totally "unexpected" errors - we generally + // assume these problems are going to magically go away at some point. + _( + "Arrange for an empty body with a 200 response - should reflect a network error." + ); + await initializeIdentityWithHAWKResponseFactory( + config, + function (method, data, uri) { + Assert.equal(method, "post"); + Assert.equal(uri, "http://mockedserver:9999/oauth/token"); + return { + status: 200, + headers: [], + body: "", + }; + } + ); + Assert.equal( + Status.login, + LOGIN_FAILED_NETWORK_ERROR, + "login state is LOGIN_FAILED_NETWORK_ERROR" + ); +}); + +// End of tests +// Utility functions follow + +// Create a new sync_auth object and initialize it with a +// hawk mock that simulates HTTP responses. +// The callback function will be called each time the mocked hawk server wants +// to make a request. The result of the callback should be the mock response +// object that will be returned to hawk. +// A token server mock will be used that doesn't hit a server, so we move +// directly to a hawk request. +async function initializeIdentityWithHAWKResponseFactory( + config, + cbGetResponse +) { + // A mock request object. + function MockRESTRequest(uri, credentials, extra) { + this._uri = uri; + this._credentials = credentials; + this._extra = extra; + } + MockRESTRequest.prototype = { + setHeader() {}, + async post(data) { + this.response = cbGetResponse( + "post", + data, + this._uri, + this._credentials, + this._extra + ); + return this.response; + }, + async get() { + // Skip /status requests (sync_auth checks if the account still + // exists after an auth error) + if (this._uri.startsWith("http://mockedserver:9999/account/status")) { + this.response = { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ exists: true }), + }; + } else { + this.response = cbGetResponse( + "get", + null, + this._uri, + this._credentials, + this._extra + ); + } + return this.response; + }, + }; + + // The hawk client. + function MockedHawkClient() {} + MockedHawkClient.prototype = new HawkClient("http://mockedserver:9999"); + MockedHawkClient.prototype.constructor = MockedHawkClient; + MockedHawkClient.prototype.newHAWKAuthenticatedRESTRequest = function ( + uri, + credentials, + extra + ) { + return new MockRESTRequest(uri, credentials, extra); + }; + // Arrange for the same observerPrefix as FxAccountsClient uses + MockedHawkClient.prototype.observerPrefix = "FxA:hawk"; + + // tie it all together - configureFxAccountIdentity isn't useful here :( + let fxaClient = new MockFxAccountsClient(); + fxaClient.hawk = new MockedHawkClient(); + let internal = { + fxAccountsClient: fxaClient, + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(config.fxaccount.user); + return new AccountState(storageManager); + }, + }; + let fxa = new FxAccounts(internal); + + globalSyncAuthManager._fxaService = fxa; + await Assert.rejects( + globalSyncAuthManager._ensureValidToken(true), + // TODO: Ideally this should have a specific check for an error. + () => true, + "expecting rejection due to hawk error" + ); +} + +function getTimestamp(hawkAuthHeader) { + return parseInt(/ts="(\d+)"/.exec(hawkAuthHeader)[1], 10) * SECOND_MS; +} + +function getTimestampDelta(hawkAuthHeader, now = Date.now()) { + return Math.abs(getTimestamp(hawkAuthHeader) - now); +} + +function mockTokenServer(func) { + let requestLog = Log.repository.getLogger("testing.mock-rest"); + if (!requestLog.appenders.length) { + // might as well see what it says :) + requestLog.addAppender(new Log.DumpAppender()); + requestLog.level = Log.Level.Trace; + } + function MockRESTRequest() {} + MockRESTRequest.prototype = { + _log: requestLog, + setHeader() {}, + async get() { + this.response = func(); + return this.response; + }, + }; + // The mocked TokenServer client which will get the response. + function MockTSC() {} + MockTSC.prototype = new TokenServerClient(); + MockTSC.prototype.constructor = MockTSC; + MockTSC.prototype.newRESTRequest = function (url) { + return new MockRESTRequest(url); + }; + // Arrange for the same observerPrefix as sync_auth uses. + MockTSC.prototype.observerPrefix = "weave:service"; + return new MockTSC(); +} diff --git a/src/zen/tests/mochitests/sync/test_syncedtabs.js b/src/zen/tests/mochitests/sync/test_syncedtabs.js new file mode 100644 index 000000000..0c7f2e480 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_syncedtabs.js @@ -0,0 +1,342 @@ +"use strict"; + +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); + +Log.repository.getLogger("Sync.RemoteTabs").addAppender(new Log.DumpAppender()); + +// A mock "Tabs" engine which the SyncedTabs module will use instead of the real +// engine. We pass a constructor that Sync creates. +function MockTabsEngine() { + this.clients = {}; // We'll set this dynamically + // Mock fxAccounts + recentDeviceList as if we hit the FxA server + this.fxAccounts = { + device: { + recentDeviceList: [ + { + id: 1, + name: "updated desktop name", + availableCommands: { + "https://identity.mozilla.com/cmd/open-uri": "baz", + }, + }, + { + id: 2, + name: "updated mobile name", + availableCommands: { + "https://identity.mozilla.com/cmd/open-uri": "boo", + }, + }, + ], + }, + }; +} + +MockTabsEngine.prototype = { + name: "tabs", + enabled: true, + + getAllClients() { + return Object.values(this.clients); + }, + + getOpenURLs() { + return new Set(); + }, +}; + +let tabsEngine; + +// A clients engine that doesn't need to be a constructor. +let MockClientsEngine = { + clientSettings: null, // Set in `configureClients`. + + isMobile(guid) { + if (!guid.endsWith("desktop") && !guid.endsWith("mobile")) { + throw new Error( + "this module expected guids to end with 'desktop' or 'mobile'" + ); + } + return guid.endsWith("mobile"); + }, + remoteClientExists(id) { + return this.clientSettings[id] !== false; + }, + getClientName(id) { + if (this.clientSettings[id]) { + return this.clientSettings[id]; + } + let client = tabsEngine.clients[id]; + let fxaDevice = tabsEngine.fxAccounts.device.recentDeviceList.find( + device => device.id === client.fxaDeviceId + ); + return fxaDevice ? fxaDevice.name : client.clientName; + }, + + getClientFxaDeviceId(id) { + if (this.clientSettings[id]) { + return this.clientSettings[id]; + } + return tabsEngine.clients[id].fxaDeviceId; + }, + + getClientType() { + return "desktop"; + }, +}; + +function configureClients(clients, clientSettings = {}) { + // each client record is expected to have an id. + for (let [guid, client] of Object.entries(clients)) { + client.id = guid; + } + tabsEngine.clients = clients; + // Apply clients collection overrides. + MockClientsEngine.clientSettings = clientSettings; + // Send an observer that pretends the engine just finished a sync. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); +} + +add_task(async function setup() { + await Weave.Service.promiseInitialized; + // Configure Sync with our mock tabs engine and force it to become initialized. + await Weave.Service.engineManager.unregister("tabs"); + await Weave.Service.engineManager.register(MockTabsEngine); + Weave.Service.clientsEngine = MockClientsEngine; + tabsEngine = Weave.Service.engineManager.get("tabs"); + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + weaveXPCService.ready = true; +}); + +// The tests. +add_task(async function test_noClients() { + // no clients, can't be tabs. + await configureClients({}); + + let tabs = await SyncedTabs.getTabClients(); + equal(Object.keys(tabs).length, 0); +}); + +add_task(async function test_clientWithTabs() { + await configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + lastUsed: 1655745700, // Mon, 20 Jun 2022 17:21:40 GMT + }, + ], + }, + guid_mobile: { + clientName: "My Phone", + tabs: [], + }, + }); + + let clients = await SyncedTabs.getTabClients(); + equal(clients.length, 2); + clients.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + let icon_url = new URL(clients[0].tabs[0].icon); + equal(icon_url.protocol, "moz-remote-image:", "image protocol is correct"); + equal(icon_url.searchParams.get("url"), "http://foo.com/favicon"); + + equal(clients[0].tabs[0].lastUsed, 1655745700); + // second client has no tabs. + equal(clients[1].tabs.length, 0); +}); + +add_task(async function test_staleClientWithTabs() { + await configureClients( + { + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + lastUsed: 1655745750, + }, + ], + }, + guid_mobile: { + clientName: "My Phone", + tabs: [], + }, + guid_stale_mobile: { + clientName: "My Deleted Phone", + tabs: [], + }, + guid_stale_desktop: { + clientName: "My Deleted Laptop", + tabs: [ + { + urlHistory: ["https://bar.com/"], + icon: "https://bar.com/favicon", + lastUsed: 1655745700, + }, + ], + }, + guid_stale_name_desktop: { + clientName: "My Generic Device", + tabs: [ + { + urlHistory: ["https://example.edu/"], + icon: "https://example.edu/favicon", + lastUsed: 1655745800, + }, + ], + }, + }, + { + guid_stale_mobile: false, + guid_stale_desktop: false, + // We should always use the device name from the clients collection, instead + // of the possibly stale tabs collection. + guid_stale_name_desktop: "My Laptop", + } + ); + let clients = await SyncedTabs.getTabClients(); + clients.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + equal(clients.length, 3); + equal(clients[0].name, "My Desktop"); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + equal(clients[0].tabs[0].lastUsed, 1655745750); + equal(clients[1].name, "My Laptop"); + equal(clients[1].tabs.length, 1); + equal(clients[1].tabs[0].url, "https://example.edu/"); + equal(clients[1].tabs[0].lastUsed, 1655745800); + equal(clients[2].name, "My Phone"); + equal(clients[2].tabs.length, 0); +}); + +add_task(async function test_clientWithTabsIconsDisabled() { + Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteIcons", false); + await configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + }, + ], + }, + }); + + let clients = await SyncedTabs.getTabClients(); + equal(clients.length, 1); + clients.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + // Expect the default favicon due to the pref being false. + equal(clients[0].tabs[0].icon, "page-icon:http://foo.com/"); + Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteIcons"); +}); + +add_task(async function test_filter() { + // Nothing matches. + await configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "A test page.", + }, + { + urlHistory: ["http://bar.com/"], + title: "Another page.", + }, + ], + }, + }); + + let clients = await SyncedTabs.getTabClients("foo"); + equal(clients.length, 1); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + // check it matches the title. + clients = await SyncedTabs.getTabClients("test"); + equal(clients.length, 1); + equal(clients[0].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); +}); + +add_task(async function test_duplicatesTabsAcrossClients() { + await configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "A test page.", + }, + ], + }, + guid_mobile: { + clientName: "My Phone", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "A test page.", + }, + ], + }, + }); + + let clients = await SyncedTabs.getTabClients(); + equal(clients.length, 2); + equal(clients[0].tabs.length, 1); + equal(clients[1].tabs.length, 1); + equal(clients[0].tabs[0].url, "http://foo.com/"); + equal(clients[1].tabs[0].url, "http://foo.com/"); +}); + +add_task(async function test_clientsTabUpdatedName() { + // See the "fxAccounts" object in the MockEngine above for the device list + await configureClients({ + guid_desktop: { + clientName: "My Desktop", + tabs: [ + { + urlHistory: ["http://foo.com/"], + icon: "http://foo.com/favicon", + }, + ], + fxaDeviceId: 1, + }, + guid_mobile: { + clientName: "My Phone", + tabs: [ + { + urlHistory: ["http://bar.com/"], + icon: "http://bar.com/favicon", + }, + ], + fxaDeviceId: 2, + }, + }); + let clients = await SyncedTabs.getTabClients(); + equal(clients.length, 2); + equal(clients[0].name, "updated desktop name"); + equal(clients[1].name, "updated mobile name"); +}); diff --git a/src/zen/tests/mochitests/sync/test_syncengine.js b/src/zen/tests/mochitests/sync/test_syncengine.js new file mode 100644 index 000000000..51dbaf8d1 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_syncengine.js @@ -0,0 +1,329 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +async function makeSteamEngine() { + let engine = new SyncEngine("Steam", Service); + await engine.initialize(); + return engine; +} + +function guidSetOfSize(length) { + return new SerializableSet(Array.from({ length }, () => Utils.makeGUID())); +} + +function assertSetsEqual(a, b) { + // Assert.deepEqual doesn't understand Set. + Assert.deepEqual(Array.from(a).sort(), Array.from(b).sort()); +} + +async function testSteamEngineStorage(test) { + try { + let setupEngine = await makeSteamEngine(); + + if (test.setup) { + await test.setup(setupEngine); + } + + // Finalize the engine to flush the backlog and previous failed to disk. + await setupEngine.finalize(); + + if (test.beforeCheck) { + await test.beforeCheck(); + } + + let checkEngine = await makeSteamEngine(); + await test.check(checkEngine); + + await checkEngine.resetClient(); + await checkEngine.finalize(); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +} + +let server; + +add_task(async function setup() { + server = httpd_setup({}); +}); + +add_task(async function test_url_attributes() { + _("SyncEngine url attributes"); + await SyncTestingInfrastructure(server); + Service.clusterURL = "https://cluster/1.1/foo/"; + let engine = await makeSteamEngine(); + try { + Assert.equal(engine.storageURL, "https://cluster/1.1/foo/storage/"); + Assert.equal(engine.engineURL, "https://cluster/1.1/foo/storage/steam"); + Assert.equal(engine.metaURL, "https://cluster/1.1/foo/storage/meta/global"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_syncID() { + _("SyncEngine.syncID corresponds to preference"); + await SyncTestingInfrastructure(server); + let engine = await makeSteamEngine(); + try { + // Ensure pristine environment + Assert.equal( + Svc.PrefBranch.getPrefType("steam.syncID"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.equal(await engine.getSyncID(), ""); + + // Performing the first get on the attribute will generate a new GUID. + Assert.equal(await engine.resetLocalSyncID(), "fake-guid-00"); + Assert.equal(Svc.PrefBranch.getStringPref("steam.syncID"), "fake-guid-00"); + + Svc.PrefBranch.setStringPref("steam.syncID", Utils.makeGUID()); + Assert.equal(Svc.PrefBranch.getStringPref("steam.syncID"), "fake-guid-01"); + Assert.equal(await engine.getSyncID(), "fake-guid-01"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_lastSync() { + _("SyncEngine.lastSync corresponds to preferences"); + await SyncTestingInfrastructure(server); + let engine = await makeSteamEngine(); + try { + // Ensure pristine environment + Assert.equal( + Svc.PrefBranch.getPrefType("steam.lastSync"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.equal(await engine.getLastSync(), 0); + + // Floats are properly stored as floats and synced with the preference + await engine.setLastSync(123.45); + Assert.equal(await engine.getLastSync(), 123.45); + Assert.equal(Svc.PrefBranch.getStringPref("steam.lastSync"), "123.45"); + + // Integer is properly stored + await engine.setLastSync(67890); + Assert.equal(await engine.getLastSync(), 67890); + Assert.equal(Svc.PrefBranch.getStringPref("steam.lastSync"), "67890"); + + // resetLastSync() resets the value (and preference) to 0 + await engine.resetLastSync(); + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(Svc.PrefBranch.getStringPref("steam.lastSync"), "0"); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_toFetch() { + _("SyncEngine.toFetch corresponds to file on disk"); + await SyncTestingInfrastructure(server); + + await testSteamEngineStorage({ + toFetch: guidSetOfSize(3), + setup(engine) { + // Ensure pristine environment + Assert.equal(engine.toFetch.size, 0); + + // Write file to disk + engine.toFetch = this.toFetch; + Assert.equal(engine.toFetch, this.toFetch); + }, + check(engine) { + // toFetch is written asynchronously + assertSetsEqual(engine.toFetch, this.toFetch); + }, + }); + + await testSteamEngineStorage({ + toFetch: guidSetOfSize(4), + toFetch2: guidSetOfSize(5), + setup(engine) { + // Make sure it work for consecutive writes before the callback is executed. + engine.toFetch = this.toFetch; + Assert.equal(engine.toFetch, this.toFetch); + + engine.toFetch = this.toFetch2; + Assert.equal(engine.toFetch, this.toFetch2); + }, + check(engine) { + assertSetsEqual(engine.toFetch, this.toFetch2); + }, + }); + + await testSteamEngineStorage({ + toFetch: guidSetOfSize(2), + async beforeCheck() { + let toFetchPath = PathUtils.join( + PathUtils.profileDir, + "weave", + "toFetch", + "steam.json" + ); + await IOUtils.writeJSON(toFetchPath, this.toFetch, { + tmpPath: toFetchPath + ".tmp", + }); + }, + check(engine) { + // Read file from disk + assertSetsEqual(engine.toFetch, this.toFetch); + }, + }); +}); + +add_task(async function test_previousFailed() { + _("SyncEngine.previousFailed values correspond to file on disk"); + await SyncTestingInfrastructure(server); + + await testSteamEngineStorage({ + previousFailedIn: guidSetOfSize(3), + previousFailedOut: guidSetOfSize(3), + setup(engine) { + // Ensure pristine environment + Assert.equal(engine.previousFailedIn.size, 0); + Assert.equal(engine.previousFailedOut.size, 0); + + // Write files to disk + engine.previousFailedIn = this.previousFailedIn; + Assert.equal(engine.previousFailedIn, this.previousFailedIn); + engine.previousFailedOut = this.previousFailedOut; + Assert.equal(engine.previousFailedOut, this.previousFailedOut); + }, + check(engine) { + // previousFailed values are written asynchronously + assertSetsEqual(engine.previousFailedIn, this.previousFailedIn); + assertSetsEqual(engine.previousFailedOut, this.previousFailedOut); + }, + }); + + await testSteamEngineStorage({ + previousFailedIn: guidSetOfSize(4), + previousFailedIn2: guidSetOfSize(5), + previousFailedOut: guidSetOfSize(4), + previousFailedOut2: guidSetOfSize(5), + setup(engine) { + // Make sure it works for consecutive writes before the callback is + // executed. + engine.previousFailedIn = this.previousFailedIn; + Assert.equal(engine.previousFailedIn, this.previousFailedIn); + engine.previousFailedOut = this.previousFailedOut; + Assert.equal(engine.previousFailedOut, this.previousFailedOut); + + engine.previousFailedIn = this.previousFailedIn2; + Assert.equal(engine.previousFailedIn, this.previousFailedIn2); + engine.previousFailedOut = this.previousFailedOut2; + Assert.equal(engine.previousFailedOut, this.previousFailedOut2); + }, + check(engine) { + assertSetsEqual(engine.previousFailedIn, this.previousFailedIn2); + assertSetsEqual(engine.previousFailedOut, this.previousFailedOut2); + }, + }); + + await testSteamEngineStorage({ + previousFailedIn: guidSetOfSize(2), + previousFailedOut: guidSetOfSize(2), + async beforeCheck() { + let previousFailedInPath = PathUtils.join( + PathUtils.profileDir, + "weave", + "failed", + "steam.json" + ); + await IOUtils.writeJSON(previousFailedInPath, this.previousFailedIn, { + tmpPath: previousFailedInPath + ".tmp", + }); + + let previousFailedOutPath = PathUtils.join( + PathUtils.profileDir, + "weave", + "failedOut", + "steam.json" + ); + await IOUtils.writeJSON(previousFailedOutPath, this.previousFailedOut, { + tmpPath: previousFailedOutPath + ".tmp", + }); + }, + check(engine) { + // Read file from disk + assertSetsEqual(engine.previousFailedIn, this.previousFailedIn); + assertSetsEqual(engine.previousFailedOut, this.previousFailedOut); + }, + }); +}); + +add_task(async function test_resetClient() { + _("SyncEngine.resetClient resets lastSync and toFetch"); + await SyncTestingInfrastructure(server); + let engine = await makeSteamEngine(); + try { + // Ensure pristine environment + Assert.equal( + Svc.PrefBranch.getPrefType("steam.lastSync"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.equal(engine.toFetch.size, 0); + + await engine.setLastSync(123.45); + engine.toFetch = guidSetOfSize(4); + engine.previousFailedIn = guidSetOfSize(3); + engine.previousFailedOut = guidSetOfSize(3); + + await engine.resetClient(); + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(engine.toFetch.size, 0); + Assert.equal(engine.previousFailedIn.size, 0); + Assert.equal(engine.previousFailedOut.size, 0); + } finally { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function test_wipeServer() { + _("SyncEngine.wipeServer deletes server data and resets the client."); + let engine = await makeSteamEngine(); + + const PAYLOAD = 42; + let steamCollection = new ServerWBO("steam", PAYLOAD); + let steamServer = httpd_setup({ + "/1.1/foo/storage/steam": steamCollection.handler(), + }); + await SyncTestingInfrastructure(steamServer); + do_test_pending(); + + try { + // Some data to reset. + await engine.setLastSync(123.45); + engine.toFetch = guidSetOfSize(3); + + _("Wipe server data and reset client."); + await engine.wipeServer(); + Assert.equal(steamCollection.payload, undefined); + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(engine.toFetch.size, 0); + } finally { + steamServer.stop(do_test_finished); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + } +}); + +add_task(async function finish() { + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_syncengine_sync.js b/src/zen/tests/mochitests/sync/test_syncengine_sync.js new file mode 100644 index 000000000..fb4beb711 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_syncengine_sync.js @@ -0,0 +1,1811 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); +const { WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { RotaryEngine } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/rotaryengine.sys.mjs" +); + +function makeRotaryEngine() { + return new RotaryEngine(Service); +} + +async function clean(engine) { + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + Svc.PrefBranch.setStringPref("log.logger.engine.rotary", "Trace"); + Service.recordManager.clearCache(); + await engine._tracker.clearChangedIDs(); + await engine.finalize(); +} + +async function cleanAndGo(engine, server) { + await clean(engine); + await promiseStopServer(server); +} + +async function promiseClean(engine, server) { + await clean(engine); + await promiseStopServer(server); +} + +async function createServerAndConfigureClient() { + let engine = new RotaryEngine(Service); + let syncID = await engine.resetLocalSyncID(); + + let contents = { + meta: { + global: { engines: { rotary: { version: engine.version, syncID } } }, + }, + crypto: {}, + rotary: {}, + }; + + const USER = "foo"; + let server = new SyncServer(); + server.registerUser(USER, "password"); + server.createContents(USER, contents); + server.start(); + + await SyncTestingInfrastructure(server, USER); + Service._updateCachedURLs(); + + return [engine, server, USER]; +} + +/* + * Tests + * + * SyncEngine._sync() is divided into four rather independent steps: + * + * - _syncStartup() + * - _processIncoming() + * - _uploadOutgoing() + * - _syncFinish() + * + * In the spirit of unit testing, these are tested individually for + * different scenarios below. + */ + +add_task(async function setup() { + await generateNewKeys(Service.collectionKeys); + Svc.PrefBranch.setStringPref("log.logger.engine.rotary", "Trace"); +}); + +add_task(async function test_syncStartup_emptyOrOutdatedGlobalsResetsSync() { + _( + "SyncEngine._syncStartup resets sync and wipes server data if there's no or an outdated global record" + ); + + // Some server side data that's going to be wiped + let collection = new ServerCollection(); + collection.insert( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + collection.insert( + "scotsman", + encryptPayload({ id: "scotsman", denomination: "Flying Scotsman" }) + ); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + engine._store.items = { rekolok: "Rekonstruktionslokomotive" }; + try { + // Confirm initial environment + const changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.rekolok, undefined); + let metaGlobal = await Service.recordManager.get(engine.metaURL); + Assert.equal(metaGlobal.payload.engines, undefined); + Assert.ok(!!collection.payload("flying")); + Assert.ok(!!collection.payload("scotsman")); + + await engine.setLastSync(Date.now() / 1000); + + // Trying to prompt a wipe -- we no longer track CryptoMeta per engine, + // so it has nothing to check. + await engine._syncStartup(); + + // The meta/global WBO has been filled with data about the engine + let engineData = metaGlobal.payload.engines.rotary; + Assert.equal(engineData.version, engine.version); + Assert.equal(engineData.syncID, await engine.getSyncID()); + + // Sync was reset and server data was wiped + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(collection.payload("flying"), undefined); + Assert.equal(collection.payload("scotsman"), undefined); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_syncStartup_serverHasNewerVersion() { + _("SyncEngine._syncStartup "); + + let global = new ServerWBO("global", { + engines: { rotary: { version: 23456 } }, + }); + let server = httpd_setup({ + "/1.1/foo/storage/meta/global": global.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + try { + // The server has a newer version of the data and our engine can + // handle. That should give us an exception. + let error; + try { + await engine._syncStartup(); + } catch (ex) { + error = ex; + } + Assert.equal(error.failureCode, VERSION_OUT_OF_DATE); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_syncStartup_syncIDMismatchResetsClient() { + _("SyncEngine._syncStartup resets sync if syncIDs don't match"); + + let server = sync_httpd_setup({}); + + await SyncTestingInfrastructure(server); + + // global record with a different syncID than our engine has + let engine = makeRotaryEngine(); + let global = new ServerWBO("global", { + engines: { rotary: { version: engine.version, syncID: "foobar" } }, + }); + server.registerPathHandler("/1.1/foo/storage/meta/global", global.handler()); + + try { + // Confirm initial environment + Assert.equal(await engine.getSyncID(), ""); + const changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.rekolok, undefined); + + await engine.setLastSync(Date.now() / 1000); + await engine._syncStartup(); + + // The engine has assumed the server's syncID + Assert.equal(await engine.getSyncID(), "foobar"); + + // Sync was reset + Assert.equal(await engine.getLastSync(), 0); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_emptyServer() { + _("SyncEngine._processIncoming working with an empty server backend"); + + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + try { + // Merely ensure that this code path is run without any errors + await engine._processIncoming(); + Assert.equal(await engine.getLastSync(), 0); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_createFromServer() { + _("SyncEngine._processIncoming creates new records from server data"); + + // Some server records that will be downloaded + let collection = new ServerCollection(); + collection.insert( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + collection.insert( + "scotsman", + encryptPayload({ id: "scotsman", denomination: "Flying Scotsman" }) + ); + + // Two pathological cases involving relative URIs gone wrong. + let pathologicalPayload = encryptPayload({ + id: "../pathological", + denomination: "Pathological Case", + }); + collection.insert("../pathological", pathologicalPayload); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler(), + }); + + await SyncTestingInfrastructure(server); + + await generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + // Confirm initial environment + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(engine.lastModified, null); + Assert.equal(engine._store.items.flying, undefined); + Assert.equal(engine._store.items.scotsman, undefined); + Assert.equal(engine._store.items["../pathological"], undefined); + + await engine._syncStartup(); + await engine._processIncoming(); + + // Timestamps of last sync and last server modification are set. + Assert.greater(await engine.getLastSync(), 0); + Assert.greater(engine.lastModified, 0); + + // Local records have been created from the server data. + Assert.equal(engine._store.items.flying, "LNER Class A3 4472"); + Assert.equal(engine._store.items.scotsman, "Flying Scotsman"); + Assert.equal(engine._store.items["../pathological"], "Pathological Case"); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_reconcile() { + _("SyncEngine._processIncoming updates local records"); + + let collection = new ServerCollection(); + + // This server record is newer than the corresponding client one, + // so it'll update its data. + collection.insert( + "newrecord", + encryptPayload({ id: "newrecord", denomination: "New stuff..." }) + ); + + // This server record is newer than the corresponding client one, + // so it'll update its data. + collection.insert( + "newerserver", + encryptPayload({ id: "newerserver", denomination: "New data!" }) + ); + + // This server record is 2 mins older than the client counterpart + // but identical to it, so we're expecting the client record's + // changedID to be reset. + collection.insert( + "olderidentical", + encryptPayload({ + id: "olderidentical", + denomination: "Older but identical", + }) + ); + collection._wbos.olderidentical.modified -= 120; + + // This item simply has different data than the corresponding client + // record (which is unmodified), so it will update the client as well + collection.insert( + "updateclient", + encryptPayload({ id: "updateclient", denomination: "Get this!" }) + ); + + // This is a dupe of 'original'. + collection.insert( + "duplication", + encryptPayload({ id: "duplication", denomination: "Original Entry" }) + ); + + // This record is marked as deleted, so we're expecting the client + // record to be removed. + collection.insert( + "nukeme", + encryptPayload({ id: "nukeme", denomination: "Nuke me!", deleted: true }) + ); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + engine._store.items = { + newerserver: "New data, but not as new as server!", + olderidentical: "Older but identical", + updateclient: "Got data?", + original: "Original Entry", + long_original: "Long Original Entry", + nukeme: "Nuke me!", + }; + // Make this record 1 min old, thus older than the one on the server + await engine._tracker.addChangedID("newerserver", Date.now() / 1000 - 60); + // This record has been changed 2 mins later than the one on the server + await engine._tracker.addChangedID("olderidentical", Date.now() / 1000); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + // Confirm initial environment + Assert.equal(engine._store.items.newrecord, undefined); + Assert.equal( + engine._store.items.newerserver, + "New data, but not as new as server!" + ); + Assert.equal(engine._store.items.olderidentical, "Older but identical"); + Assert.equal(engine._store.items.updateclient, "Got data?"); + Assert.equal(engine._store.items.nukeme, "Nuke me!"); + let changes = await engine._tracker.getChangedIDs(); + Assert.greater(changes.olderidentical, 0); + + await engine._syncStartup(); + await engine._processIncoming(); + + // Timestamps of last sync and last server modification are set. + Assert.greater(await engine.getLastSync(), 0); + Assert.greater(engine.lastModified, 0); + + // The new record is created. + Assert.equal(engine._store.items.newrecord, "New stuff..."); + + // The 'newerserver' record is updated since the server data is newer. + Assert.equal(engine._store.items.newerserver, "New data!"); + + // The data for 'olderidentical' is identical on the server, so + // it's no longer marked as changed anymore. + Assert.equal(engine._store.items.olderidentical, "Older but identical"); + changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.olderidentical, undefined); + + // Updated with server data. + Assert.equal(engine._store.items.updateclient, "Get this!"); + + // The incoming ID is preferred. + Assert.equal(engine._store.items.original, undefined); + Assert.equal(engine._store.items.duplication, "Original Entry"); + Assert.notEqual(engine._delete.ids.indexOf("original"), -1); + + // The 'nukeme' record marked as deleted is removed. + Assert.equal(engine._store.items.nukeme, undefined); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_reconcile_local_deleted() { + _("Ensure local, duplicate ID is deleted on server."); + + // When a duplicate is resolved, the local ID (which is never taken) should + // be deleted on the server. + let [engine, server, user] = await createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + await engine.setLastSync(now); + engine.lastModified = now + 1; + + let record = encryptPayload({ + id: "DUPE_INCOMING", + denomination: "incoming", + }); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + record = encryptPayload({ id: "DUPE_LOCAL", denomination: "local" }); + wbo = new ServerWBO("DUPE_LOCAL", record, now - 1); + server.insertWBO(user, "rotary", wbo); + + await engine._store.create({ id: "DUPE_LOCAL", denomination: "local" }); + Assert.ok(await engine._store.itemExists("DUPE_LOCAL")); + Assert.equal("DUPE_LOCAL", await engine._findDupe({ id: "DUPE_INCOMING" })); + + await engine._sync(); + + do_check_attribute_count(engine._store.items, 1); + Assert.ok("DUPE_INCOMING" in engine._store.items); + + let collection = server.getCollection(user, "rotary"); + Assert.equal(1, collection.count()); + Assert.notEqual(undefined, collection.wbo("DUPE_INCOMING")); + + await cleanAndGo(engine, server); +}); + +add_task(async function test_processIncoming_reconcile_equivalent() { + _("Ensure proper handling of incoming records that match local."); + + let [engine, server, user] = await createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + await engine.setLastSync(now); + engine.lastModified = now + 1; + + let record = encryptPayload({ id: "entry", denomination: "denomination" }); + let wbo = new ServerWBO("entry", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + engine._store.items = { entry: "denomination" }; + Assert.ok(await engine._store.itemExists("entry")); + + await engine._sync(); + + do_check_attribute_count(engine._store.items, 1); + + await cleanAndGo(engine, server); +}); + +add_task( + async function test_processIncoming_reconcile_locally_deleted_dupe_new() { + _( + "Ensure locally deleted duplicate record newer than incoming is handled." + ); + + // This is a somewhat complicated test. It ensures that if a client receives + // a modified record for an item that is deleted locally but with a different + // ID that the incoming record is ignored. This is a corner case for record + // handling, but it needs to be supported. + let [engine, server, user] = await createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + await engine.setLastSync(now); + engine.lastModified = now + 1; + + let record = encryptPayload({ + id: "DUPE_INCOMING", + denomination: "incoming", + }); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + // Simulate a locally-deleted item. + engine._store.items = {}; + await engine._tracker.addChangedID("DUPE_LOCAL", now + 3); + Assert.equal(false, await engine._store.itemExists("DUPE_LOCAL")); + Assert.equal(false, await engine._store.itemExists("DUPE_INCOMING")); + Assert.equal("DUPE_LOCAL", await engine._findDupe({ id: "DUPE_INCOMING" })); + + engine.lastModified = server.getCollection(user, engine.name).timestamp; + await engine._sync(); + + // After the sync, the server's payload for the original ID should be marked + // as deleted. + do_check_empty(engine._store.items); + let collection = server.getCollection(user, "rotary"); + Assert.equal(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + Assert.notEqual(null, wbo); + let payload = wbo.getCleartext(); + Assert.ok(payload.deleted); + + await cleanAndGo(engine, server); + } +); + +add_task( + async function test_processIncoming_reconcile_locally_deleted_dupe_old() { + _( + "Ensure locally deleted duplicate record older than incoming is restored." + ); + + // This is similar to the above test except it tests the condition where the + // incoming record is newer than the local deletion, therefore overriding it. + + let [engine, server, user] = await createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + await engine.setLastSync(now); + engine.lastModified = now + 1; + + let record = encryptPayload({ + id: "DUPE_INCOMING", + denomination: "incoming", + }); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + // Simulate a locally-deleted item. + engine._store.items = {}; + await engine._tracker.addChangedID("DUPE_LOCAL", now + 1); + Assert.equal(false, await engine._store.itemExists("DUPE_LOCAL")); + Assert.equal(false, await engine._store.itemExists("DUPE_INCOMING")); + Assert.equal("DUPE_LOCAL", await engine._findDupe({ id: "DUPE_INCOMING" })); + + await engine._sync(); + + // Since the remote change is newer, the incoming item should exist locally. + do_check_attribute_count(engine._store.items, 1); + Assert.ok("DUPE_INCOMING" in engine._store.items); + Assert.equal("incoming", engine._store.items.DUPE_INCOMING); + + let collection = server.getCollection(user, "rotary"); + Assert.equal(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + let payload = wbo.getCleartext(); + Assert.equal("incoming", payload.denomination); + + await cleanAndGo(engine, server); + } +); + +add_task(async function test_processIncoming_reconcile_changed_dupe() { + _("Ensure that locally changed duplicate record is handled properly."); + + let [engine, server, user] = await createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + await engine.setLastSync(now); + engine.lastModified = now + 1; + + // The local record is newer than the incoming one, so it should be retained. + let record = encryptPayload({ + id: "DUPE_INCOMING", + denomination: "incoming", + }); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + await engine._store.create({ id: "DUPE_LOCAL", denomination: "local" }); + await engine._tracker.addChangedID("DUPE_LOCAL", now + 3); + Assert.ok(await engine._store.itemExists("DUPE_LOCAL")); + Assert.equal("DUPE_LOCAL", await engine._findDupe({ id: "DUPE_INCOMING" })); + + engine.lastModified = server.getCollection(user, engine.name).timestamp; + await engine._sync(); + + // The ID should have been changed to incoming. + do_check_attribute_count(engine._store.items, 1); + Assert.ok("DUPE_INCOMING" in engine._store.items); + + // On the server, the local ID should be deleted and the incoming ID should + // have its payload set to what was in the local record. + let collection = server.getCollection(user, "rotary"); + Assert.equal(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + Assert.notEqual(undefined, wbo); + let payload = wbo.getCleartext(); + Assert.equal("local", payload.denomination); + + await cleanAndGo(engine, server); +}); + +add_task(async function test_processIncoming_reconcile_changed_dupe_new() { + _("Ensure locally changed duplicate record older than incoming is ignored."); + + // This test is similar to the above except the incoming record is younger + // than the local record. The incoming record should be authoritative. + let [engine, server, user] = await createServerAndConfigureClient(); + + let now = Date.now() / 1000 - 10; + await engine.setLastSync(now); + engine.lastModified = now + 1; + + let record = encryptPayload({ + id: "DUPE_INCOMING", + denomination: "incoming", + }); + let wbo = new ServerWBO("DUPE_INCOMING", record, now + 2); + server.insertWBO(user, "rotary", wbo); + + await engine._store.create({ id: "DUPE_LOCAL", denomination: "local" }); + await engine._tracker.addChangedID("DUPE_LOCAL", now + 1); + Assert.ok(await engine._store.itemExists("DUPE_LOCAL")); + Assert.equal("DUPE_LOCAL", await engine._findDupe({ id: "DUPE_INCOMING" })); + + engine.lastModified = server.getCollection(user, engine.name).timestamp; + await engine._sync(); + + // The ID should have been changed to incoming. + do_check_attribute_count(engine._store.items, 1); + Assert.ok("DUPE_INCOMING" in engine._store.items); + + // On the server, the local ID should be deleted and the incoming ID should + // have its payload retained. + let collection = server.getCollection(user, "rotary"); + Assert.equal(1, collection.count()); + wbo = collection.wbo("DUPE_INCOMING"); + Assert.notEqual(undefined, wbo); + let payload = wbo.getCleartext(); + Assert.equal("incoming", payload.denomination); + await cleanAndGo(engine, server); +}); + +add_task(async function test_processIncoming_resume_toFetch() { + _( + "toFetch and previousFailed items left over from previous syncs are fetched on the next sync, along with new items." + ); + + const LASTSYNC = Date.now() / 1000; + + // Server records that will be downloaded + let collection = new ServerCollection(); + collection.insert( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + collection.insert( + "scotsman", + encryptPayload({ id: "scotsman", denomination: "Flying Scotsman" }) + ); + collection.insert( + "rekolok", + encryptPayload({ id: "rekolok", denomination: "Rekonstruktionslokomotive" }) + ); + for (let i = 0; i < 3; i++) { + let id = "failed" + i; + let payload = encryptPayload({ id, denomination: "Record No. " + i }); + let wbo = new ServerWBO(id, payload); + wbo.modified = LASTSYNC - 10; + collection.insertWBO(wbo); + } + + collection.wbo("flying").modified = collection.wbo("scotsman").modified = + LASTSYNC - 10; + collection._wbos.rekolok.modified = LASTSYNC + 10; + + // Time travel 10 seconds into the future but still download the above WBOs. + let engine = makeRotaryEngine(); + await engine.setLastSync(LASTSYNC); + engine.toFetch = new SerializableSet(["flying", "scotsman"]); + engine.previousFailedIn = new SerializableSet([ + "failed0", + "failed1", + "failed2", + ]); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + try { + // Confirm initial environment + Assert.equal(engine._store.items.flying, undefined); + Assert.equal(engine._store.items.scotsman, undefined); + Assert.equal(engine._store.items.rekolok, undefined); + + await engine._syncStartup(); + await engine._processIncoming(); + + // Local records have been created from the server data. + Assert.equal(engine._store.items.flying, "LNER Class A3 4472"); + Assert.equal(engine._store.items.scotsman, "Flying Scotsman"); + Assert.equal(engine._store.items.rekolok, "Rekonstruktionslokomotive"); + Assert.equal(engine._store.items.failed0, "Record No. 0"); + Assert.equal(engine._store.items.failed1, "Record No. 1"); + Assert.equal(engine._store.items.failed2, "Record No. 2"); + Assert.equal(engine.previousFailedIn.size, 0); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_notify_count() { + _("Ensure that failed records are reported only once."); + + const NUMBER_OF_RECORDS = 15; + + // Engine that fails every 5 records. + let engine = makeRotaryEngine(); + engine._store._applyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = async function (records, countTelemetry) { + let sortedRecords = records.sort((a, b) => (a.id > b.id ? 1 : -1)); + let recordsToApply = [], + recordsToFail = []; + for (let i = 0; i < sortedRecords.length; i++) { + (i % 5 === 0 ? recordsToFail : recordsToApply).push(sortedRecords[i]); + } + recordsToFail.forEach(() => { + countTelemetry.addIncomingFailedReason("failed message"); + }); + await engine._store._applyIncomingBatch(recordsToApply, countTelemetry); + + return recordsToFail.map(record => record.id); + }; + + // Create a batch of server side records. + let collection = new ServerCollection(); + for (var i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = "record-no-" + i.toString(10).padStart(2, "0"); + let payload = encryptPayload({ id, denomination: "Record No. " + id }); + collection.insert(id, payload); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + try { + // Confirm initial environment. + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(engine.toFetch.size, 0); + Assert.equal(engine.previousFailedIn.size, 0); + do_check_empty(engine._store.items); + + let called = 0; + let counts; + function onApplied(count) { + _("Called with " + JSON.stringify(counts)); + counts = count; + called++; + } + Svc.Obs.add("weave:engine:sync:applied", onApplied); + + // Do sync. + await engine._syncStartup(); + await engine._processIncoming(); + + // Confirm failures. + do_check_attribute_count(engine._store.items, 12); + Assert.deepEqual( + Array.from(engine.previousFailedIn).sort(), + ["record-no-00", "record-no-05", "record-no-10"].sort() + ); + + // There are newly failed records and they are reported. + Assert.equal(called, 1); + Assert.equal(counts.failed, 3); + Assert.equal(counts.failedReasons[0].count, 3); + Assert.equal(counts.failedReasons[0].name, "failed message"); + Assert.equal(counts.applied, 15); + Assert.equal(counts.newFailed, 3); + Assert.equal(counts.succeeded, 12); + + // Sync again, 1 of the failed items are the same, the rest didn't fail. + await engine._processIncoming(); + + // Confirming removed failures. + do_check_attribute_count(engine._store.items, 14); + // After failing twice the record that failed again [record-no-00] + // should NOT be stored to try again + Assert.deepEqual(Array.from(engine.previousFailedIn), []); + + Assert.equal(called, 2); + Assert.equal(counts.failed, 1); + Assert.equal(counts.failedReasons[0].count, 1); + Assert.equal(counts.failedReasons[0].name, "failed message"); + Assert.equal(counts.applied, 3); + Assert.equal(counts.newFailed, 0); + Assert.equal(counts.succeeded, 2); + + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_previousFailed() { + _("Ensure that failed records are retried."); + + const NUMBER_OF_RECORDS = 14; + + // Engine that alternates between failing and applying every 2 records. + let engine = makeRotaryEngine(); + engine._store._applyIncomingBatch = engine._store.applyIncomingBatch; + engine._store.applyIncomingBatch = async function (records, countTelemetry) { + let sortedRecords = records.sort((a, b) => (a.id > b.id ? 1 : -1)); + let recordsToApply = [], + recordsToFail = []; + let chunks = Array.from(PlacesUtils.chunkArray(sortedRecords, 2)); + for (let i = 0; i < chunks.length; i++) { + (i % 2 === 0 ? recordsToFail : recordsToApply).push(...chunks[i]); + } + await engine._store._applyIncomingBatch(recordsToApply, countTelemetry); + return recordsToFail.map(record => record.id); + }; + + // Create a batch of server side records. + let collection = new ServerCollection(); + for (var i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = "record-no-" + i.toString(10).padStart(2, "0"); + let payload = encryptPayload({ id, denomination: "Record No. " + i }); + collection.insert(id, payload); + } + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + try { + // Confirm initial environment. + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(engine.toFetch.size, 0); + Assert.equal(engine.previousFailedIn.size, 0); + do_check_empty(engine._store.items); + + // Initial failed items in previousFailedIn to be reset. + let previousFailedIn = new SerializableSet([ + Utils.makeGUID(), + Utils.makeGUID(), + Utils.makeGUID(), + ]); + engine.previousFailedIn = previousFailedIn; + Assert.equal(engine.previousFailedIn, previousFailedIn); + + // Do sync. + await engine._syncStartup(); + await engine._processIncoming(); + + // Expected result: 4 sync batches with 2 failures each => 8 failures + do_check_attribute_count(engine._store.items, 6); + Assert.deepEqual( + Array.from(engine.previousFailedIn).sort(), + [ + "record-no-00", + "record-no-01", + "record-no-04", + "record-no-05", + "record-no-08", + "record-no-09", + "record-no-12", + "record-no-13", + ].sort() + ); + + // Sync again with the same failed items (records 0, 1, 8, 9). + await engine._processIncoming(); + + do_check_attribute_count(engine._store.items, 10); + // A second sync with the same failed items should NOT add the same items again. + // Items that did not fail a second time should no longer be in previousFailedIn. + Assert.deepEqual(Array.from(engine.previousFailedIn).sort(), []); + + // Refetched items that didn't fail the second time are in engine._store.items. + Assert.equal(engine._store.items["record-no-04"], "Record No. 4"); + Assert.equal(engine._store.items["record-no-05"], "Record No. 5"); + Assert.equal(engine._store.items["record-no-12"], "Record No. 12"); + Assert.equal(engine._store.items["record-no-13"], "Record No. 13"); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_failed_records() { + _( + "Ensure that failed records from _reconcile and applyIncomingBatch are refetched." + ); + + // Let's create three and a bit batches worth of server side records. + let APPLY_BATCH_SIZE = 50; + let collection = new ServerCollection(); + const NUMBER_OF_RECORDS = APPLY_BATCH_SIZE * 3 + 5; + for (let i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = "record-no-" + i; + let payload = encryptPayload({ id, denomination: "Record No. " + id }); + let wbo = new ServerWBO(id, payload); + wbo.modified = Date.now() / 1000 + 60 * (i - APPLY_BATCH_SIZE * 3); + collection.insertWBO(wbo); + } + + // Engine that batches but likes to throw on a couple of records, + // two in each batch: the even ones fail in reconcile, the odd ones + // in applyIncoming. + const BOGUS_RECORDS = [ + "record-no-" + 42, + "record-no-" + 23, + "record-no-" + (42 + APPLY_BATCH_SIZE), + "record-no-" + (23 + APPLY_BATCH_SIZE), + "record-no-" + (42 + APPLY_BATCH_SIZE * 2), + "record-no-" + (23 + APPLY_BATCH_SIZE * 2), + "record-no-" + (2 + APPLY_BATCH_SIZE * 3), + "record-no-" + (1 + APPLY_BATCH_SIZE * 3), + ]; + let engine = makeRotaryEngine(); + + engine.__reconcile = engine._reconcile; + engine._reconcile = async function _reconcile(record) { + if (BOGUS_RECORDS.indexOf(record.id) % 2 == 0) { + throw new Error("I don't like this record! Baaaaaah!"); + } + return this.__reconcile.apply(this, arguments); + }; + engine._store._applyIncoming = engine._store.applyIncoming; + engine._store.applyIncoming = async function (record) { + if (BOGUS_RECORDS.indexOf(record.id) % 2 == 1) { + throw new Error("I don't like this record! Baaaaaah!"); + } + return this._applyIncoming.apply(this, arguments); + }; + + // Keep track of requests made of a collection. + let count = 0; + let uris = []; + function recording_handler(recordedCollection) { + let h = recordedCollection.handler(); + return function (req, res) { + ++count; + uris.push(req.path + "?" + req.queryString); + return h(req, res); + }; + } + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": recording_handler(collection), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + // Confirm initial environment + Assert.equal(await engine.getLastSync(), 0); + Assert.equal(engine.toFetch.size, 0); + Assert.equal(engine.previousFailedIn.size, 0); + do_check_empty(engine._store.items); + + let observerSubject; + let observerData; + Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) { + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + observerSubject = subject; + observerData = data; + }); + + await engine._syncStartup(); + await engine._processIncoming(); + + // Ensure that all records but the bogus 4 have been applied. + do_check_attribute_count( + engine._store.items, + NUMBER_OF_RECORDS - BOGUS_RECORDS.length + ); + + // Ensure that the bogus records will be fetched again on the next sync. + Assert.equal(engine.previousFailedIn.size, BOGUS_RECORDS.length); + Assert.deepEqual( + Array.from(engine.previousFailedIn).sort(), + BOGUS_RECORDS.sort() + ); + + // Ensure the observer was notified + Assert.equal(observerData, engine.name); + Assert.equal(observerSubject.failed, BOGUS_RECORDS.length); + Assert.equal(observerSubject.newFailed, BOGUS_RECORDS.length); + + // Testing batching of failed item fetches. + // Try to sync again. Ensure that we split the request into chunks to avoid + // URI length limitations. + async function batchDownload(batchSize) { + count = 0; + uris = []; + engine.guidFetchBatchSize = batchSize; + await engine._processIncoming(); + _("Tried again. Requests: " + count + "; URIs: " + JSON.stringify(uris)); + return count; + } + + // There are 8 bad records, so this needs 3 fetches. + _("Test batching with ID batch size 3, normal mobile batch size."); + Assert.equal(await batchDownload(3), 3); + + // Since there the previous batch failed again, there should be + // no more records to fetch + _("Test that the second time a record failed to sync, gets ignored"); + Assert.equal(await batchDownload(BOGUS_RECORDS.length), 0); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_processIncoming_decrypt_failed() { + _("Ensure that records failing to decrypt are either replaced or refetched."); + + // Some good and some bogus records. One doesn't contain valid JSON, + // the other will throw during decrypt. + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + collection._wbos.nojson = new ServerWBO("nojson", "This is invalid JSON"); + collection._wbos.nojson2 = new ServerWBO("nojson2", "This is invalid JSON"); + collection._wbos.scotsman = new ServerWBO( + "scotsman", + encryptPayload({ id: "scotsman", denomination: "Flying Scotsman" }) + ); + collection._wbos.nodecrypt = new ServerWBO("nodecrypt", "Decrypt this!"); + collection._wbos.nodecrypt2 = new ServerWBO("nodecrypt2", "Decrypt this!"); + + // Patch the fake crypto service to throw on the record above. + Weave.Crypto._decrypt = Weave.Crypto.decrypt; + Weave.Crypto.decrypt = function (ciphertext) { + if (ciphertext == "Decrypt this!") { + throw new Error( + "Derp! Cipher finalized failed. Im ur crypto destroyin ur recordz." + ); + } + return this._decrypt.apply(this, arguments); + }; + + // Some broken records also exist locally. + let engine = makeRotaryEngine(); + engine.enabled = true; + engine._store.items = { nojson: "Valid JSON", nodecrypt: "Valid ciphertext" }; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + try { + // Confirm initial state + Assert.equal(engine.toFetch.size, 0); + Assert.equal(engine.previousFailedIn.size, 0); + + let observerSubject; + let observerData; + Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) { + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + observerSubject = subject; + observerData = data; + }); + + await engine.setLastSync(collection.wbo("nojson").modified - 1); + let ping = await sync_engine_and_validate_telem(engine, true); + Assert.equal(ping.engines[0].incoming.applied, 2); + Assert.equal(ping.engines[0].incoming.failed, 4); + console.log("incoming telem: ", ping.engines[0].incoming); + Assert.equal( + ping.engines[0].incoming.failedReasons[0].name, + "No ciphertext: nothing to decrypt?" + ); + // There should be 4 of the same error + Assert.equal(ping.engines[0].incoming.failedReasons[0].count, 4); + + Assert.equal(engine.previousFailedIn.size, 4); + Assert.ok(engine.previousFailedIn.has("nojson")); + Assert.ok(engine.previousFailedIn.has("nojson2")); + Assert.ok(engine.previousFailedIn.has("nodecrypt")); + Assert.ok(engine.previousFailedIn.has("nodecrypt2")); + + // Ensure the observer was notified + Assert.equal(observerData, engine.name); + Assert.equal(observerSubject.applied, 2); + Assert.equal(observerSubject.failed, 4); + Assert.equal(observerSubject.failedReasons[0].count, 4); + } finally { + await promiseClean(engine, server); + } +}); + +add_task(async function test_uploadOutgoing_toEmptyServer() { + _("SyncEngine._uploadOutgoing uploads new records to server"); + + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO("flying"); + collection._wbos.scotsman = new ServerWBO("scotsman"); + collection._wbos.failed = new ServerWBO("failed"); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler(), + "/1.1/foo/storage/rotary/failed": collection.wbo("failed").handler(), + }); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + failed: "Previously Failed Item", + failagain: "Item that failed and will fail again", + }; + // Mark some of these records as changed + await engine._tracker.addChangedID("scotsman", 0); + await engine._tracker.addChangedID("failed", 0); + await engine._tracker.addChangedID("failagain", 0); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + await engine.setLastSync(123); // needs to be non-zero so that tracker is queried + engine.previousFailedOut = new SerializableSet(["failed", "failagain"]); + + // Confirm initial environment + Assert.equal(collection.payload("flying"), undefined); + Assert.equal(collection.payload("scotsman"), undefined); + Assert.equal(collection.payload("failed"), undefined); + + await engine._syncStartup(); + await engine._uploadOutgoing(); + + // Ensure the marked record ('scotsman') and previously failed record + // ('failed') have been uploaded and are no longer marked. + Assert.equal(collection.payload("flying"), undefined); + Assert.ok(!!collection.payload("scotsman")); + Assert.equal(collection.cleartext("scotsman").id, "scotsman"); + const changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.scotsman, undefined); + Assert.ok(!!collection.payload("failed")); + Assert.equal(collection.cleartext("failed").id, "failed"); + Assert.equal(changes.failed, undefined); + + // The 'flying' record wasn't marked and 'failagain' failed, so they + // weren't uploaded + Assert.equal(collection.payload("flying"), undefined); + Assert.equal(changes.flying, undefined); + Assert.equal(changes.failagain, undefined); + + // Nothing failed on this run except failagain' which we already retried, + // so this should be empty. + Assert.equal(engine.previousFailedOut.size, 0); + } finally { + await cleanAndGo(engine, server); + } +}); + +async function test_uploadOutgoing_max_record_payload_bytes( + allowSkippedRecord +) { + _( + "SyncEngine._uploadOutgoing throws when payload is bigger than max_record_payload_bytes" + ); + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO("flying"); + collection._wbos.scotsman = new ServerWBO("scotsman"); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(), + "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler(), + }); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + engine.allowSkippedRecord = allowSkippedRecord; + engine._store.items = { flying: "a".repeat(1024 * 1024), scotsman: "abcd" }; + + await engine._tracker.addChangedID("flying", 1000); + await engine._tracker.addChangedID("scotsman", 1000); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + await engine.setLastSync(1); // needs to be non-zero so that tracker is queried + + // Confirm initial environment + Assert.equal(collection.payload("flying"), undefined); + Assert.equal(collection.payload("scotsman"), undefined); + + await engine._syncStartup(); + await engine._uploadOutgoing(); + + if (!allowSkippedRecord) { + do_throw("should not get here"); + } + + await engine.trackRemainingChanges(); + + // Check we uploaded the other record to the server + Assert.ok(collection.payload("scotsman")); + // And that we won't try to upload the huge record next time. + const changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.flying, undefined); + } catch (e) { + if (allowSkippedRecord) { + do_throw("should not get here"); + } + + await engine.trackRemainingChanges(); + + // Check that we will try to upload the huge record next time + const changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.flying, 1000); + } finally { + // Check we didn't upload the oversized record to the server + Assert.equal(collection.payload("flying"), undefined); + await cleanAndGo(engine, server); + } +} + +add_task( + async function test_uploadOutgoing_max_record_payload_bytes_disallowSkippedRecords() { + return test_uploadOutgoing_max_record_payload_bytes(false); + } +); + +add_task( + async function test_uploadOutgoing_max_record_payload_bytes_allowSkippedRecords() { + return test_uploadOutgoing_max_record_payload_bytes(true); + } +); + +add_task(async function test_uploadOutgoing_failed() { + _( + "SyncEngine._uploadOutgoing doesn't clear the tracker of objects that failed to upload." + ); + + let collection = new ServerCollection(); + // We only define the "flying" WBO on the server, not the "scotsman" + // and "peppercorn" ones. + collection._wbos.flying = new ServerWBO("flying"); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + peppercorn: "Peppercorn Class", + }; + // Mark these records as changed + const FLYING_CHANGED = 12345; + const SCOTSMAN_CHANGED = 23456; + const PEPPERCORN_CHANGED = 34567; + await engine._tracker.addChangedID("flying", FLYING_CHANGED); + await engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED); + await engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + await engine.setLastSync(123); // needs to be non-zero so that tracker is queried + + // Confirm initial environment + Assert.equal(collection.payload("flying"), undefined); + let changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.flying, FLYING_CHANGED); + Assert.equal(changes.scotsman, SCOTSMAN_CHANGED); + Assert.equal(changes.peppercorn, PEPPERCORN_CHANGED); + + engine.enabled = true; + await sync_engine_and_validate_telem(engine, true); + + // Ensure the 'flying' record has been uploaded and is no longer marked. + Assert.ok(!!collection.payload("flying")); + changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.flying, undefined); + + // The 'scotsman' and 'peppercorn' records couldn't be uploaded so + // they were added to `previousFailedOut` and weren't cleared from the + // tracker. + Assert.equal(changes.scotsman, SCOTSMAN_CHANGED); + Assert.equal(changes.peppercorn, PEPPERCORN_CHANGED); + Assert.equal(engine.previousFailedOut.size, 2); + Assert.ok(engine.previousFailedOut.has("scotsman")); + Assert.ok(engine.previousFailedOut.has("peppercorn")); + } finally { + await promiseClean(engine, server); + } +}); + +async function createRecordFailTelemetry(allowSkippedRecord) { + Services.prefs.setStringPref("services.sync.username", "foo"); + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO("flying"); + collection._wbos.scotsman = new ServerWBO("scotsman"); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + engine.allowSkippedRecord = allowSkippedRecord; + let oldCreateRecord = engine._store.createRecord; + engine._store.createRecord = async (id, col) => { + if (id != "flying") { + throw new Error("oops"); + } + return oldCreateRecord.call(engine._store, id, col); + }; + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + }; + // Mark these records as changed + const FLYING_CHANGED = 12345; + const SCOTSMAN_CHANGED = 23456; + await engine._tracker.addChangedID("flying", FLYING_CHANGED); + await engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + let ping; + try { + await engine.setLastSync(123); // needs to be non-zero so that tracker is queried + + // Confirm initial environment + Assert.equal(collection.payload("flying"), undefined); + let changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.flying, FLYING_CHANGED); + Assert.equal(changes.scotsman, SCOTSMAN_CHANGED); + + engine.enabled = true; + ping = await sync_engine_and_validate_telem(engine, true, onErrorPing => { + ping = onErrorPing; + }); + + if (!allowSkippedRecord) { + do_throw("should not get here"); + } + + // Ensure the 'flying' record has been uploaded and is no longer marked. + Assert.ok(!!collection.payload("flying")); + changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.flying, undefined); + } catch (err) { + if (allowSkippedRecord) { + do_throw("should not get here"); + } + + // Ensure the 'flying' record has not been uploaded and is still marked + Assert.ok(!collection.payload("flying")); + const changes = await engine._tracker.getChangedIDs(); + Assert.ok(changes.flying); + } finally { + // We reported in telemetry that we failed a record + Assert.equal(ping.engines[0].outgoing[0].failed, 1); + Assert.equal(ping.engines[0].outgoing[0].failedReasons[0].name, "oops"); + + // In any case, the 'scotsman' record couldn't be created so it wasn't + // uploaded nor it was not cleared from the tracker. + Assert.ok(!collection.payload("scotsman")); + const changes = await engine._tracker.getChangedIDs(); + Assert.equal(changes.scotsman, SCOTSMAN_CHANGED); + + engine._store.createRecord = oldCreateRecord; + await promiseClean(engine, server); + } +} + +add_task( + async function test_uploadOutgoing_createRecord_throws_reported_telemetry() { + _( + "SyncEngine._uploadOutgoing reports a failed record to telemetry if createRecord throws" + ); + await createRecordFailTelemetry(true); + } +); + +add_task( + async function test_uploadOutgoing_createRecord_throws_dontAllowSkipRecord() { + _( + "SyncEngine._uploadOutgoing will throw if createRecord throws and allowSkipRecord is set to false" + ); + await createRecordFailTelemetry(false); + } +); + +add_task(async function test_uploadOutgoing_largeRecords() { + _( + "SyncEngine._uploadOutgoing throws on records larger than the max record payload size" + ); + + let collection = new ServerCollection(); + + let engine = makeRotaryEngine(); + engine.allowSkippedRecord = false; + engine._store.items["large-item"] = "Y".repeat( + Service.getMaxRecordPayloadSize() * 2 + ); + await engine._tracker.addChangedID("large-item", 0); + collection.insert("large-item"); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + try { + await engine._syncStartup(); + let error = null; + try { + await engine._uploadOutgoing(); + } catch (e) { + error = e; + } + ok(!!error); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_syncFinish_deleteByIds() { + _( + "SyncEngine._syncFinish deletes server records slated for deletion (list of record IDs)." + ); + + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + collection._wbos.scotsman = new ServerWBO( + "scotsman", + encryptPayload({ id: "scotsman", denomination: "Flying Scotsman" }) + ); + collection._wbos.rekolok = new ServerWBO( + "rekolok", + encryptPayload({ id: "rekolok", denomination: "Rekonstruktionslokomotive" }) + ); + + let server = httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + try { + engine._delete = { ids: ["flying", "rekolok"] }; + await engine._syncFinish(); + + // The 'flying' and 'rekolok' records were deleted while the + // 'scotsman' one wasn't. + Assert.equal(collection.payload("flying"), undefined); + Assert.ok(!!collection.payload("scotsman")); + Assert.equal(collection.payload("rekolok"), undefined); + + // The deletion todo list has been reset. + Assert.equal(engine._delete.ids, undefined); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_syncFinish_deleteLotsInBatches() { + _( + "SyncEngine._syncFinish deletes server records in batches of 100 (list of record IDs)." + ); + + let collection = new ServerCollection(); + + // Let's count how many times the client does a DELETE request to the server + var noOfUploads = 0; + collection.delete = (function (orig) { + return function () { + noOfUploads++; + return orig.apply(this, arguments); + }; + })(collection.delete); + + // Create a bunch of records on the server + let now = Date.now(); + for (var i = 0; i < 234; i++) { + let id = "record-no-" + i; + let payload = encryptPayload({ id, denomination: "Record No. " + i }); + let wbo = new ServerWBO(id, payload); + wbo.modified = now / 1000 - 60 * (i + 110); + collection.insertWBO(wbo); + } + + let server = httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let engine = makeRotaryEngine(); + try { + // Confirm initial environment + Assert.equal(noOfUploads, 0); + + // Declare what we want to have deleted: all records no. 100 and + // up and all records that are less than 200 mins old (which are + // records 0 thru 90). + engine._delete = { ids: [], newer: now / 1000 - 60 * 200.5 }; + for (i = 100; i < 234; i++) { + engine._delete.ids.push("record-no-" + i); + } + + await engine._syncFinish(); + + // Ensure that the appropriate server data has been wiped while + // preserving records 90 thru 200. + for (i = 0; i < 234; i++) { + let id = "record-no-" + i; + if (i <= 90 || i >= 100) { + Assert.equal(collection.payload(id), undefined); + } else { + Assert.ok(!!collection.payload(id)); + } + } + + // The deletion was done in batches + Assert.equal(noOfUploads, 2 + 1); + + // The deletion todo list has been reset. + Assert.equal(engine._delete.ids, undefined); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_sync_partialUpload() { + _("SyncEngine.sync() keeps changedIDs that couldn't be uploaded."); + + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + let oldServerConfiguration = Service.serverConfiguration; + Service.serverConfiguration = { + max_post_records: 100, + }; + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let engine = makeRotaryEngine(); + + // Let the third upload fail completely + var noOfUploads = 0; + collection.post = (function (orig) { + return function () { + if (noOfUploads == 2) { + throw new Error("FAIL!"); + } + noOfUploads++; + return orig.apply(this, arguments); + }; + })(collection.post); + + // Create a bunch of records (and server side handlers) + for (let i = 0; i < 234; i++) { + let id = "record-no-" + i; + engine._store.items[id] = "Record No. " + i; + await engine._tracker.addChangedID(id, i); + // Let two items in the first upload batch fail. + if (i != 23 && i != 42) { + collection.insert(id); + } + } + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + await engine.setLastSync(123); // needs to be non-zero so that tracker is queried + + engine.enabled = true; + let error; + try { + await sync_engine_and_validate_telem(engine, true); + } catch (ex) { + error = ex; + } + + ok(!!error); + + const changes = await engine._tracker.getChangedIDs(); + for (let i = 0; i < 234; i++) { + let id = "record-no-" + i; + // Ensure that failed records are back in the tracker and failures have + // been added to `previousFailedOut`. + if (i < 200) { + // Records no. 23 and 42 were rejected by the server, + if (i == 23 || i == 42) { + Assert.equal(changes[id], i); + Assert.ok(engine.previousFailedOut.has(id)); + } else { + Assert.equal(false, id in changes); + Assert.ok(!engine.previousFailedOut.has(id)); + } + } else { + // Records after the third batch and higher couldn't be uploaded because + // we failed hard on the 3rd upload. + Assert.equal(changes[id], i); + Assert.ok(!engine.previousFailedOut.has(id)); + } + } + } finally { + Service.serverConfiguration = oldServerConfiguration; + await promiseClean(engine, server); + } +}); + +add_task(async function test_canDecrypt_noCryptoKeys() { + _( + "SyncEngine.canDecrypt returns false if the engine fails to decrypt items on the server, e.g. due to a missing crypto key collection." + ); + + // Wipe collection keys so we can test the desired scenario. + Service.collectionKeys.clear(); + + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + let engine = makeRotaryEngine(); + try { + Assert.equal(false, await engine.canDecrypt()); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_canDecrypt_true() { + _( + "SyncEngine.canDecrypt returns true if the engine can decrypt the items on the server." + ); + + await generateNewKeys(Service.collectionKeys); + + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO( + "flying", + encryptPayload({ id: "flying", denomination: "LNER Class A3 4472" }) + ); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + let engine = makeRotaryEngine(); + try { + Assert.ok(await engine.canDecrypt()); + } finally { + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_syncapplied_observer() { + const NUMBER_OF_RECORDS = 10; + + let engine = makeRotaryEngine(); + + // Create a batch of server side records. + let collection = new ServerCollection(); + for (var i = 0; i < NUMBER_OF_RECORDS; i++) { + let id = "record-no-" + i; + let payload = encryptPayload({ id, denomination: "Record No. " + id }); + collection.insert(id, payload); + } + + let server = httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + let numApplyCalls = 0; + let engine_name; + let count; + function onApplied(subject, data) { + numApplyCalls++; + engine_name = data; + count = subject; + } + + Svc.Obs.add("weave:engine:sync:applied", onApplied); + + try { + Service.scheduler.hasIncomingItems = false; + + // Do sync. + await engine._syncStartup(); + await engine._processIncoming(); + + do_check_attribute_count(engine._store.items, 10); + + Assert.equal(numApplyCalls, 1); + Assert.equal(engine_name, "rotary"); + Assert.equal(count.applied, 10); + + Assert.ok(Service.scheduler.hasIncomingItems); + } finally { + await cleanAndGo(engine, server); + Service.scheduler.hasIncomingItems = false; + Svc.Obs.remove("weave:engine:sync:applied", onApplied); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_syncscheduler.js b/src/zen/tests/mochitests/sync/test_syncscheduler.js new file mode 100644 index 000000000..d46b9fc6c --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_syncscheduler.js @@ -0,0 +1,1207 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { SyncAuthManager } = ChromeUtils.importESModule( + "resource://services-sync/sync_auth.sys.mjs" +); +const { SyncScheduler } = ChromeUtils.importESModule( + "resource://services-sync/policies.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { Status } = ChromeUtils.importESModule( + "resource://services-sync/status.sys.mjs" +); + +function CatapultEngine() { + SyncEngine.call(this, "Catapult", Service); +} +CatapultEngine.prototype = { + exception: null, // tests fill this in + async _sync() { + throw this.exception; + }, +}; +Object.setPrototypeOf(CatapultEngine.prototype, SyncEngine.prototype); + +var scheduler = new SyncScheduler(Service); +let clientsEngine; + +async function sync_httpd_setup() { + let clientsSyncID = await clientsEngine.resetLocalSyncID(); + let global = new ServerWBO("global", { + syncID: Service.syncID, + storageVersion: STORAGE_VERSION, + engines: { + clients: { version: clientsEngine.version, syncID: clientsSyncID }, + }, + }); + let clientsColl = new ServerCollection({}, true); + + // Tracking info/collections. + let collectionsHelper = track_collections_helper(); + let upd = collectionsHelper.with_updated_collection; + + return httpd_setup({ + "/1.1/johndoe@mozilla.com/storage/meta/global": upd( + "meta", + global.handler() + ), + "/1.1/johndoe@mozilla.com/info/collections": collectionsHelper.handler, + "/1.1/johndoe@mozilla.com/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe@mozilla.com/storage/clients": upd( + "clients", + clientsColl.handler() + ), + }); +} + +async function setUp(server) { + await configureIdentity({ username: "johndoe@mozilla.com" }, server); + + await generateNewKeys(Service.collectionKeys); + let serverKeys = Service.collectionKeys.asWBO("crypto", "keys"); + await serverKeys.encrypt(Service.identity.syncKeyBundle); + let result = ( + await serverKeys.upload(Service.resource(Service.cryptoKeysURL)) + ).success; + return result; +} + +async function cleanUpAndGo(server) { + await Async.promiseYield(); + await clientsEngine._store.wipe(); + await Service.startOver(); + // Re-enable logging, which we just disabled. + syncTestLogging(); + if (server) { + await promiseStopServer(server); + } +} + +add_task(async function setup() { + await Service.promiseInitialized; + clientsEngine = Service.clientsEngine; + // Don't remove stale clients when syncing. This is a test-only workaround + // that lets us add clients directly to the store, without losing them on + // the next sync. + clientsEngine._removeRemoteClient = async () => {}; + await Service.engineManager.clear(); + + validate_all_future_pings(); + + scheduler.setDefaults(); + + await Service.engineManager.register(CatapultEngine); +}); + +add_test(function test_prefAttributes() { + _("Test various attributes corresponding to preferences."); + + const INTERVAL = 42 * 60 * 1000; // 42 minutes + const THRESHOLD = 3142; + const SCORE = 2718; + const TIMESTAMP1 = 1275493471649; + + _( + "The 'nextSync' attribute stores a millisecond timestamp rounded down to the nearest second." + ); + Assert.equal(scheduler.nextSync, 0); + scheduler.nextSync = TIMESTAMP1; + Assert.equal(scheduler.nextSync, Math.floor(TIMESTAMP1 / 1000) * 1000); + + _("'syncInterval' defaults to singleDeviceInterval."); + Assert.equal( + Svc.PrefBranch.getPrefType("syncInterval"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + _("'syncInterval' corresponds to a preference setting."); + scheduler.syncInterval = INTERVAL; + Assert.equal(scheduler.syncInterval, INTERVAL); + Assert.equal(Svc.PrefBranch.getIntPref("syncInterval"), INTERVAL); + + _( + "'syncThreshold' corresponds to preference, defaults to SINGLE_USER_THRESHOLD" + ); + Assert.equal( + Svc.PrefBranch.getPrefType("syncThreshold"), + Ci.nsIPrefBranch.PREF_INVALID + ); + Assert.equal(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + scheduler.syncThreshold = THRESHOLD; + Assert.equal(scheduler.syncThreshold, THRESHOLD); + + _("'globalScore' corresponds to preference, defaults to zero."); + Assert.equal(Svc.PrefBranch.getIntPref("globalScore"), 0); + Assert.equal(scheduler.globalScore, 0); + scheduler.globalScore = SCORE; + Assert.equal(scheduler.globalScore, SCORE); + Assert.equal(Svc.PrefBranch.getIntPref("globalScore"), SCORE); + + _("Intervals correspond to default preferences."); + Assert.equal( + scheduler.singleDeviceInterval, + Svc.PrefBranch.getIntPref("scheduler.fxa.singleDeviceInterval") * 1000 + ); + Assert.equal( + scheduler.idleInterval, + Svc.PrefBranch.getIntPref("scheduler.idleInterval") * 1000 + ); + Assert.equal( + scheduler.activeInterval, + Svc.PrefBranch.getIntPref("scheduler.activeInterval") * 1000 + ); + Assert.equal( + scheduler.immediateInterval, + Svc.PrefBranch.getIntPref("scheduler.immediateInterval") * 1000 + ); + + _("Custom values for prefs will take effect after a restart."); + Svc.PrefBranch.setIntPref("scheduler.fxa.singleDeviceInterval", 420); + Svc.PrefBranch.setIntPref("scheduler.idleInterval", 230); + Svc.PrefBranch.setIntPref("scheduler.activeInterval", 180); + Svc.PrefBranch.setIntPref("scheduler.immediateInterval", 31415); + scheduler.setDefaults(); + Assert.equal(scheduler.idleInterval, 230000); + Assert.equal(scheduler.singleDeviceInterval, 420000); + Assert.equal(scheduler.activeInterval, 180000); + Assert.equal(scheduler.immediateInterval, 31415000); + + _("Custom values for interval prefs can't be less than 60 seconds."); + Svc.PrefBranch.setIntPref("scheduler.fxa.singleDeviceInterval", 42); + Svc.PrefBranch.setIntPref("scheduler.idleInterval", 50); + Svc.PrefBranch.setIntPref("scheduler.activeInterval", 50); + Svc.PrefBranch.setIntPref("scheduler.immediateInterval", 10); + scheduler.setDefaults(); + Assert.equal(scheduler.idleInterval, 60000); + Assert.equal(scheduler.singleDeviceInterval, 60000); + Assert.equal(scheduler.activeInterval, 60000); + Assert.equal(scheduler.immediateInterval, 60000); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + scheduler.setDefaults(); + run_next_test(); +}); + +add_task(async function test_sync_skipped_low_score_no_resync() { + enableValidationPrefs(); + let server = await sync_httpd_setup(); + + function SkipEngine() { + SyncEngine.call(this, "Skip", Service); + this.syncs = 0; + } + + SkipEngine.prototype = { + _sync() { + do_throw("Should have been skipped"); + }, + shouldSkipSync() { + return true; + }, + }; + Object.setPrototypeOf(SkipEngine.prototype, SyncEngine.prototype); + await Service.engineManager.register(SkipEngine); + + let engine = Service.engineManager.get("skip"); + engine.enabled = true; + engine._tracker._score = 30; + + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await setUp(server)); + + let resyncDoneObserver = promiseOneObserver("weave:service:resyncs-finished"); + + let synced = false; + function onSyncStarted() { + Assert.ok(!synced, "Only should sync once"); + synced = true; + } + + await Service.sync(); + + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Svc.Obs.add("weave:service:sync:start", onSyncStarted); + await resyncDoneObserver; + + Svc.Obs.remove("weave:service:sync:start", onSyncStarted); + engine._tracker._store = 0; + await cleanUpAndGo(server); +}); + +add_task(async function test_updateClientMode() { + _( + "Test updateClientMode adjusts scheduling attributes based on # of clients appropriately" + ); + Assert.equal(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.idle); + + // Trigger a change in interval & threshold by noting there are multiple clients. + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.updateClientMode(); + + Assert.equal(scheduler.syncThreshold, MULTI_DEVICE_THRESHOLD); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.idle); + + // Resets the number of clients to 0. + await clientsEngine.resetClient(); + Svc.PrefBranch.clearUserPref("clients.devices.mobile"); + scheduler.updateClientMode(); + + // Goes back to single user if # clients is 1. + Assert.equal(scheduler.numClients, 1); + Assert.equal(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.idle); + + await cleanUpAndGo(); +}); + +add_task(async function test_masterpassword_locked_retry_interval() { + enableValidationPrefs(); + + _( + "Test Status.login = MASTER_PASSWORD_LOCKED results in reschedule at MASTER_PASSWORD interval" + ); + let loginFailed = false; + Svc.Obs.add("weave:service:login:error", function onLoginError() { + Svc.Obs.remove("weave:service:login:error", onLoginError); + loginFailed = true; + }); + + let rescheduleInterval = false; + + let oldScheduleAtInterval = SyncScheduler.prototype.scheduleAtInterval; + SyncScheduler.prototype.scheduleAtInterval = function (interval) { + rescheduleInterval = true; + Assert.equal(interval, MASTER_PASSWORD_LOCKED_RETRY_INTERVAL); + }; + + let oldVerifyLogin = Service.verifyLogin; + Service.verifyLogin = async function () { + Status.login = MASTER_PASSWORD_LOCKED; + return false; + }; + + let server = await sync_httpd_setup(); + await setUp(server); + + await Service.sync(); + + Assert.ok(loginFailed); + Assert.equal(Status.login, MASTER_PASSWORD_LOCKED); + Assert.ok(rescheduleInterval); + + Service.verifyLogin = oldVerifyLogin; + SyncScheduler.prototype.scheduleAtInterval = oldScheduleAtInterval; + + await cleanUpAndGo(server); +}); + +add_task(async function test_calculateBackoff() { + Assert.equal(Status.backoffInterval, 0); + + // Test no interval larger than the maximum backoff is used if + // Status.backoffInterval is smaller. + Status.backoffInterval = 5; + let backoffInterval = Utils.calculateBackoff( + 50, + MAXIMUM_BACKOFF_INTERVAL, + Status.backoffInterval + ); + + Assert.equal(backoffInterval, MAXIMUM_BACKOFF_INTERVAL); + + // Test Status.backoffInterval is used if it is + // larger than MAXIMUM_BACKOFF_INTERVAL. + Status.backoffInterval = MAXIMUM_BACKOFF_INTERVAL + 10; + backoffInterval = Utils.calculateBackoff( + 50, + MAXIMUM_BACKOFF_INTERVAL, + Status.backoffInterval + ); + + Assert.equal(backoffInterval, MAXIMUM_BACKOFF_INTERVAL + 10); + + await cleanUpAndGo(); +}); + +add_task(async function test_scheduleNextSync_nowOrPast() { + enableValidationPrefs(); + + let promiseObserved = promiseOneObserver("weave:service:sync:finish"); + + let server = await sync_httpd_setup(); + await setUp(server); + + // We're late for a sync... + scheduler.scheduleNextSync(-1); + await promiseObserved; + await cleanUpAndGo(server); +}); + +add_task(async function test_scheduleNextSync_future_noBackoff() { + enableValidationPrefs(); + + _( + "scheduleNextSync() uses the current syncInterval if no interval is provided." + ); + // Test backoffInterval is 0 as expected. + Assert.equal(Status.backoffInterval, 0); + + _("Test setting sync interval when nextSync == 0"); + scheduler.nextSync = 0; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + Assert.lessOrEqual(scheduler.nextSync - Date.now(), scheduler.syncInterval); + Assert.equal(scheduler.syncTimer.delay, scheduler.syncInterval); + + _("Test setting sync interval when nextSync != 0"); + scheduler.nextSync = Date.now() + scheduler.singleDeviceInterval; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + Assert.lessOrEqual(scheduler.nextSync - Date.now(), scheduler.syncInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, scheduler.syncInterval); + + _( + "Scheduling requests for intervals larger than the current one will be ignored." + ); + // Request a sync at a longer interval. The sync that's already scheduled + // for sooner takes precedence. + let nextSync = scheduler.nextSync; + let timerDelay = scheduler.syncTimer.delay; + let requestedInterval = scheduler.syncInterval * 10; + scheduler.scheduleNextSync(requestedInterval); + Assert.equal(scheduler.nextSync, nextSync); + Assert.equal(scheduler.syncTimer.delay, timerDelay); + + // We can schedule anything we want if there isn't a sync scheduled. + scheduler.nextSync = 0; + scheduler.scheduleNextSync(requestedInterval); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + requestedInterval); + Assert.equal(scheduler.syncTimer.delay, requestedInterval); + + // Request a sync at the smallest possible interval (0 triggers now). + scheduler.scheduleNextSync(1); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + 1); + Assert.equal(scheduler.syncTimer.delay, 1); + + await cleanUpAndGo(); +}); + +add_task(async function test_scheduleNextSync_future_backoff() { + enableValidationPrefs(); + + _("scheduleNextSync() will honour backoff in all scheduling requests."); + // Let's take a backoff interval that's bigger than the default sync interval. + const BACKOFF = 7337; + Status.backoffInterval = scheduler.syncInterval + BACKOFF; + + _("Test setting sync interval when nextSync == 0"); + scheduler.nextSync = 0; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + Assert.lessOrEqual(scheduler.nextSync - Date.now(), Status.backoffInterval); + Assert.equal(scheduler.syncTimer.delay, Status.backoffInterval); + + _("Test setting sync interval when nextSync != 0"); + scheduler.nextSync = Date.now() + scheduler.singleDeviceInterval; + scheduler.scheduleNextSync(); + + // nextSync - Date.now() might be smaller than expectedInterval + // since some time has passed since we called scheduleNextSync(). + Assert.lessOrEqual(scheduler.nextSync - Date.now(), Status.backoffInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, Status.backoffInterval); + + // Request a sync at a longer interval. The sync that's already scheduled + // for sooner takes precedence. + let nextSync = scheduler.nextSync; + let timerDelay = scheduler.syncTimer.delay; + let requestedInterval = scheduler.syncInterval * 10; + Assert.greater(requestedInterval, Status.backoffInterval); + scheduler.scheduleNextSync(requestedInterval); + Assert.equal(scheduler.nextSync, nextSync); + Assert.equal(scheduler.syncTimer.delay, timerDelay); + + // We can schedule anything we want if there isn't a sync scheduled. + scheduler.nextSync = 0; + scheduler.scheduleNextSync(requestedInterval); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + requestedInterval); + Assert.equal(scheduler.syncTimer.delay, requestedInterval); + + // Request a sync at the smallest possible interval (0 triggers now). + scheduler.scheduleNextSync(1); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + Status.backoffInterval); + Assert.equal(scheduler.syncTimer.delay, Status.backoffInterval); + + await cleanUpAndGo(); +}); + +add_task(async function test_handleSyncError() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Force sync to fail. + Svc.PrefBranch.setStringPref("firstSync", "notReady"); + + _("Ensure expected initial environment."); + Assert.equal(scheduler._syncErrors, 0); + Assert.ok(!Status.enforceBackoff); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.equal(Status.backoffInterval, 0); + + // Trigger sync with an error several times & observe + // functionality of handleSyncError() + _("Test first error calls scheduleNextSync on default interval"); + await Service.sync(); + Assert.lessOrEqual( + scheduler.nextSync, + Date.now() + scheduler.singleDeviceInterval + ); + Assert.equal(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + Assert.equal(scheduler._syncErrors, 1); + Assert.ok(!Status.enforceBackoff); + scheduler.syncTimer.clear(); + + _("Test second error still calls scheduleNextSync on default interval"); + await Service.sync(); + Assert.lessOrEqual( + scheduler.nextSync, + Date.now() + scheduler.singleDeviceInterval + ); + Assert.equal(scheduler.syncTimer.delay, scheduler.singleDeviceInterval); + Assert.equal(scheduler._syncErrors, 2); + Assert.ok(!Status.enforceBackoff); + scheduler.syncTimer.clear(); + + _("Test third error sets Status.enforceBackoff and calls scheduleAtInterval"); + await Service.sync(); + let maxInterval = scheduler._syncErrors * (2 * MINIMUM_BACKOFF_INTERVAL); + Assert.equal(Status.backoffInterval, 0); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + maxInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, maxInterval); + Assert.equal(scheduler._syncErrors, 3); + Assert.ok(Status.enforceBackoff); + + // Status.enforceBackoff is false but there are still errors. + Status.resetBackoff(); + Assert.ok(!Status.enforceBackoff); + Assert.equal(scheduler._syncErrors, 3); + scheduler.syncTimer.clear(); + + _( + "Test fourth error still calls scheduleAtInterval even if enforceBackoff was reset" + ); + await Service.sync(); + maxInterval = scheduler._syncErrors * (2 * MINIMUM_BACKOFF_INTERVAL); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + maxInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, maxInterval); + Assert.equal(scheduler._syncErrors, 4); + Assert.ok(Status.enforceBackoff); + scheduler.syncTimer.clear(); + + _("Arrange for a successful sync to reset the scheduler error count"); + let promiseObserved = promiseOneObserver("weave:service:sync:finish"); + Svc.PrefBranch.setStringPref("firstSync", "wipeRemote"); + scheduler.scheduleNextSync(-1); + await promiseObserved; + await cleanUpAndGo(server); +}); + +add_task(async function test_client_sync_finish_updateClientMode() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Confirm defaults. + Assert.equal(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.ok(!scheduler.idle); + + // Trigger a change in interval & threshold by adding a client. + await clientsEngine._store.create({ + id: "foo", + cleartext: { os: "mobile", version: "0.01", type: "desktop" }, + }); + Assert.equal(false, scheduler.numClients > 1); + scheduler.updateClientMode(); + await Service.sync(); + + Assert.equal(scheduler.syncThreshold, MULTI_DEVICE_THRESHOLD); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + Assert.greater(scheduler.numClients, 1); + Assert.ok(!scheduler.idle); + + // Resets the number of clients to 0. + await clientsEngine.resetClient(); + // Also re-init the server, or we suck our "foo" client back down. + await setUp(server); + + await Service.sync(); + + // Goes back to single user if # clients is 1. + Assert.equal(scheduler.numClients, 1); + Assert.equal(scheduler.syncThreshold, SINGLE_USER_THRESHOLD); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + Assert.equal(false, scheduler.numClients > 1); + Assert.ok(!scheduler.idle); + + await cleanUpAndGo(server); +}); + +add_task(async function test_autoconnect_nextSync_past() { + enableValidationPrefs(); + + let promiseObserved = promiseOneObserver("weave:service:sync:finish"); + // nextSync will be 0 by default, so it's way in the past. + + let server = await sync_httpd_setup(); + await setUp(server); + + scheduler.autoConnect(); + await promiseObserved; + await cleanUpAndGo(server); +}); + +add_task(async function test_autoconnect_nextSync_future() { + enableValidationPrefs(); + + let previousSync = Date.now() + scheduler.syncInterval / 2; + scheduler.nextSync = previousSync; + // nextSync rounds to the nearest second. + let expectedSync = scheduler.nextSync; + let expectedInterval = expectedSync - Date.now() - 1000; + + // Ensure we don't actually try to sync (or log in for that matter). + function onLoginStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + await configureIdentity({ username: "johndoe@mozilla.com" }); + scheduler.autoConnect(); + await promiseZeroTimer(); + + Assert.equal(scheduler.nextSync, expectedSync); + Assert.greaterOrEqual(scheduler.syncTimer.delay, expectedInterval); + + Svc.Obs.remove("weave:service:login:start", onLoginStart); + await cleanUpAndGo(); +}); + +add_task(async function test_autoconnect_mp_locked() { + let server = await sync_httpd_setup(); + await setUp(server); + + // Pretend user did not unlock master password. + let origLocked = Utils.mpLocked; + Utils.mpLocked = () => true; + + let origEnsureMPUnlocked = Utils.ensureMPUnlocked; + Utils.ensureMPUnlocked = () => { + _("Faking Master Password entry cancelation."); + return false; + }; + let origFxA = Service.identity._fxaService; + Service.identity._fxaService = new FxAccounts({ + currentAccountState: { + getUserAccountData(...args) { + return origFxA._internal.currentAccountState.getUserAccountData( + ...args + ); + }, + }, + keys: { + canGetKeyForScope() { + return false; + }, + }, + }); + // A locked master password will still trigger a sync, but then we'll hit + // MASTER_PASSWORD_LOCKED and hence MASTER_PASSWORD_LOCKED_RETRY_INTERVAL. + let promiseObserved = promiseOneObserver("weave:service:login:error"); + + scheduler.autoConnect(); + await promiseObserved; + + await Async.promiseYield(); + + Assert.equal(Status.login, MASTER_PASSWORD_LOCKED); + + Utils.mpLocked = origLocked; + Utils.ensureMPUnlocked = origEnsureMPUnlocked; + Service.identity._fxaService = origFxA; + + await cleanUpAndGo(server); +}); + +add_task(async function test_no_autoconnect_during_wizard() { + let server = await sync_httpd_setup(); + await setUp(server); + + // Simulate the Sync setup wizard. + Svc.PrefBranch.setStringPref("firstSync", "notReady"); + + // Ensure we don't actually try to sync (or log in for that matter). + function onLoginStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + scheduler.autoConnect(0); + await promiseZeroTimer(); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + await cleanUpAndGo(server); +}); + +add_task(async function test_no_autoconnect_status_not_ok() { + let server = await sync_httpd_setup(); + Status.__authManager = Service.identity = new SyncAuthManager(); + + // Ensure we don't actually try to sync (or log in for that matter). + function onLoginStart() { + do_throw("Should not get here!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + scheduler.autoConnect(); + await promiseZeroTimer(); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + + Assert.equal(Status.service, CLIENT_NOT_CONFIGURED); + Assert.equal(Status.login, LOGIN_FAILED_NO_USERNAME); + + await cleanUpAndGo(server); +}); + +add_task(async function test_idle_adjustSyncInterval() { + // Confirm defaults. + Assert.equal(scheduler.idle, false); + + // Single device: nothing changes. + scheduler.observe( + null, + "idle", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.equal(scheduler.idle, true); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); + + // Multiple devices: switch to idle interval. + scheduler.idle = false; + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.updateClientMode(); + scheduler.observe( + null, + "idle", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.equal(scheduler.idle, true); + Assert.equal(scheduler.syncInterval, scheduler.idleInterval); + + await cleanUpAndGo(); +}); + +add_task(async function test_back_triggersSync() { + // Confirm defaults. + Assert.ok(!scheduler.idle); + Assert.equal(Status.backoffInterval, 0); + + // Set up: Define 2 clients and put the system in idle. + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.observe( + null, + "idle", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.ok(scheduler.idle); + + // We don't actually expect the sync (or the login, for that matter) to + // succeed. We just want to ensure that it was attempted. + let promiseObserved = promiseOneObserver("weave:service:login:error"); + + // Send an 'active' event to trigger sync soonish. + scheduler.observe( + null, + "active", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + await promiseObserved; + await cleanUpAndGo(); +}); + +add_task(async function test_active_triggersSync_observesBackoff() { + // Confirm defaults. + Assert.ok(!scheduler.idle); + + // Set up: Set backoff, define 2 clients and put the system in idle. + const BACKOFF = 7337; + Status.backoffInterval = scheduler.idleInterval + BACKOFF; + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.observe( + null, + "idle", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.equal(scheduler.idle, true); + + function onLoginStart() { + do_throw("Shouldn't have kicked off a sync!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + let promiseTimer = promiseNamedTimer( + IDLE_OBSERVER_BACK_DELAY * 1.5, + {}, + "timer" + ); + + // Send an 'active' event to try to trigger sync soonish. + scheduler.observe( + null, + "active", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + await promiseTimer; + Svc.Obs.remove("weave:service:login:start", onLoginStart); + + Assert.lessOrEqual(scheduler.nextSync, Date.now() + Status.backoffInterval); + Assert.equal(scheduler.syncTimer.delay, Status.backoffInterval); + + await cleanUpAndGo(); +}); + +add_task(async function test_back_debouncing() { + _( + "Ensure spurious back-then-idle events, as observed on OS X, don't trigger a sync." + ); + + // Confirm defaults. + Assert.equal(scheduler.idle, false); + + // Set up: Define 2 clients and put the system in idle. + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.observe( + null, + "idle", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + Assert.equal(scheduler.idle, true); + + function onLoginStart() { + do_throw("Shouldn't have kicked off a sync!"); + } + Svc.Obs.add("weave:service:login:start", onLoginStart); + + // Create spurious back-then-idle events as observed on OS X: + scheduler.observe( + null, + "active", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + scheduler.observe( + null, + "idle", + Svc.PrefBranch.getIntPref("scheduler.idleTime") + ); + + await promiseNamedTimer(IDLE_OBSERVER_BACK_DELAY * 1.5, {}, "timer"); + Svc.Obs.remove("weave:service:login:start", onLoginStart); + await cleanUpAndGo(); +}); + +add_task(async function test_no_sync_node() { + enableValidationPrefs(); + + // Test when Status.sync == NO_SYNC_NODE_FOUND + // it is not overwritten on sync:finish + let server = await sync_httpd_setup(); + await setUp(server); + + let oldfc = Service.identity._findCluster; + Service.identity._findCluster = () => null; + Service.clusterURL = ""; + try { + await Service.sync(); + Assert.equal(Status.sync, NO_SYNC_NODE_FOUND); + Assert.equal(scheduler.syncTimer.delay, NO_SYNC_NODE_INTERVAL); + + await cleanUpAndGo(server); + } finally { + Service.identity._findCluster = oldfc; + } +}); + +add_task(async function test_sync_failed_partial_500s() { + enableValidationPrefs(); + + _("Test a 5xx status calls handleSyncError."); + scheduler._syncErrors = MAX_ERROR_COUNT_BEFORE_BACKOFF; + let server = await sync_httpd_setup(); + + let engine = Service.engineManager.get("catapult"); + engine.enabled = true; + engine.exception = { status: 500 }; + + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await setUp(server)); + + await Service.sync(); + + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + + let maxInterval = scheduler._syncErrors * (2 * MINIMUM_BACKOFF_INTERVAL); + Assert.equal(Status.backoffInterval, 0); + Assert.ok(Status.enforceBackoff); + Assert.equal(scheduler._syncErrors, 4); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + maxInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, maxInterval); + + await cleanUpAndGo(server); +}); + +add_task(async function test_sync_failed_partial_noresync() { + enableValidationPrefs(); + let server = await sync_httpd_setup(); + + let engine = Service.engineManager.get("catapult"); + engine.enabled = true; + engine.exception = "Bad news"; + engine._tracker._score = MULTI_DEVICE_THRESHOLD + 1; + + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await setUp(server)); + + let resyncDoneObserver = promiseOneObserver("weave:service:resyncs-finished"); + + await Service.sync(); + + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + + function onSyncStarted() { + do_throw("Should not start resync when previous sync failed"); + } + + Svc.Obs.add("weave:service:sync:start", onSyncStarted); + await resyncDoneObserver; + + Svc.Obs.remove("weave:service:sync:start", onSyncStarted); + engine._tracker._store = 0; + await cleanUpAndGo(server); +}); + +add_task(async function test_sync_failed_partial_400s() { + enableValidationPrefs(); + + _("Test a non-5xx status doesn't call handleSyncError."); + scheduler._syncErrors = MAX_ERROR_COUNT_BEFORE_BACKOFF; + let server = await sync_httpd_setup(); + + let engine = Service.engineManager.get("catapult"); + engine.enabled = true; + engine.exception = { status: 400 }; + + // Have multiple devices for an active interval. + await clientsEngine._store.create({ + id: "foo", + cleartext: { os: "mobile", version: "0.01", type: "desktop" }, + }); + + Assert.equal(Status.sync, SYNC_SUCCEEDED); + + Assert.ok(await setUp(server)); + + await Service.sync(); + + Assert.equal(Status.service, SYNC_FAILED_PARTIAL); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + + Assert.equal(Status.backoffInterval, 0); + Assert.ok(!Status.enforceBackoff); + Assert.equal(scheduler._syncErrors, 0); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + scheduler.activeInterval); + Assert.lessOrEqual(scheduler.syncTimer.delay, scheduler.activeInterval); + + await cleanUpAndGo(server); +}); + +add_task(async function test_sync_X_Weave_Backoff() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Use an odd value on purpose so that it doesn't happen to coincide with one + // of the sync intervals. + const BACKOFF = 7337; + + // Extend info/collections so that we can put it into server maintenance mode. + const INFO_COLLECTIONS = "/1.1/johndoe@mozilla.com/info/collections"; + let infoColl = server._handler._overridePaths[INFO_COLLECTIONS]; + let serverBackoff = false; + function infoCollWithBackoff(request, response) { + if (serverBackoff) { + response.setHeader("X-Weave-Backoff", "" + BACKOFF); + } + infoColl(request, response); + } + server.registerPathHandler(INFO_COLLECTIONS, infoCollWithBackoff); + + // Pretend we have two clients so that the regular sync interval is + // sufficiently low. + await clientsEngine._store.create({ + id: "foo", + cleartext: { os: "mobile", version: "0.01", type: "desktop" }, + }); + let rec = await clientsEngine._store.createRecord("foo", "clients"); + await rec.encrypt(Service.collectionKeys.keyForCollection("clients")); + await rec.upload(Service.resource(clientsEngine.engineURL + rec.id)); + + // Sync once to log in and get everything set up. Let's verify our initial + // values. + await Service.sync(); + Assert.equal(Status.backoffInterval, 0); + Assert.equal(Status.minimumNextSync, 0); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + scheduler.syncInterval); + // Sanity check that we picked the right value for BACKOFF: + Assert.less(scheduler.syncInterval, BACKOFF * 1000); + + // Turn on server maintenance and sync again. + serverBackoff = true; + await Service.sync(); + + Assert.greaterOrEqual(Status.backoffInterval, BACKOFF * 1000); + // Allowing 20 seconds worth of of leeway between when Status.minimumNextSync + // was set and when this line gets executed. + let minimumExpectedDelay = (BACKOFF - 20) * 1000; + Assert.greaterOrEqual( + Status.minimumNextSync, + Date.now() + minimumExpectedDelay + ); + + // Verify that the next sync is actually going to wait that long. + Assert.greaterOrEqual(scheduler.nextSync, Date.now() + minimumExpectedDelay); + Assert.greaterOrEqual(scheduler.syncTimer.delay, minimumExpectedDelay); + + await cleanUpAndGo(server); +}); + +add_task(async function test_sync_503_Retry_After() { + enableValidationPrefs(); + + let server = await sync_httpd_setup(); + await setUp(server); + + // Use an odd value on purpose so that it doesn't happen to coincide with one + // of the sync intervals. + const BACKOFF = 7337; + + // Extend info/collections so that we can put it into server maintenance mode. + const INFO_COLLECTIONS = "/1.1/johndoe@mozilla.com/info/collections"; + let infoColl = server._handler._overridePaths[INFO_COLLECTIONS]; + let serverMaintenance = false; + function infoCollWithMaintenance(request, response) { + if (!serverMaintenance) { + infoColl(request, response); + return; + } + response.setHeader("Retry-After", "" + BACKOFF); + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + } + server.registerPathHandler(INFO_COLLECTIONS, infoCollWithMaintenance); + + // Pretend we have two clients so that the regular sync interval is + // sufficiently low. + await clientsEngine._store.create({ + id: "foo", + cleartext: { os: "mobile", version: "0.01", type: "desktop" }, + }); + let rec = await clientsEngine._store.createRecord("foo", "clients"); + await rec.encrypt(Service.collectionKeys.keyForCollection("clients")); + await rec.upload(Service.resource(clientsEngine.engineURL + rec.id)); + + // Sync once to log in and get everything set up. Let's verify our initial + // values. + await Service.sync(); + Assert.ok(!Status.enforceBackoff); + Assert.equal(Status.backoffInterval, 0); + Assert.equal(Status.minimumNextSync, 0); + Assert.equal(scheduler.syncInterval, scheduler.activeInterval); + Assert.lessOrEqual(scheduler.nextSync, Date.now() + scheduler.syncInterval); + // Sanity check that we picked the right value for BACKOFF: + Assert.less(scheduler.syncInterval, BACKOFF * 1000); + + // Turn on server maintenance and sync again. + serverMaintenance = true; + await Service.sync(); + + Assert.ok(Status.enforceBackoff); + Assert.greaterOrEqual(Status.backoffInterval, BACKOFF * 1000); + // Allowing 3 seconds worth of of leeway between when Status.minimumNextSync + // was set and when this line gets executed. + let minimumExpectedDelay = (BACKOFF - 3) * 1000; + Assert.greaterOrEqual( + Status.minimumNextSync, + Date.now() + minimumExpectedDelay + ); + + // Verify that the next sync is actually going to wait that long. + Assert.greaterOrEqual(scheduler.nextSync, Date.now() + minimumExpectedDelay); + Assert.greaterOrEqual(scheduler.syncTimer.delay, minimumExpectedDelay); + + await cleanUpAndGo(server); +}); + +add_task(async function test_loginError_recoverable_reschedules() { + _("Verify that a recoverable login error schedules a new sync."); + await configureIdentity({ username: "johndoe@mozilla.com" }); + Service.clusterURL = "http://localhost:1234/"; + Status.resetSync(); // reset Status.login + + let promiseObserved = promiseOneObserver("weave:service:login:error"); + + // Let's set it up so that a sync is overdue, both in terms of previously + // scheduled syncs and the global score. We still do not expect an immediate + // sync because we just tried (duh). + scheduler.nextSync = Date.now() - 100000; + scheduler.globalScore = SINGLE_USER_THRESHOLD + 1; + function onSyncStart() { + do_throw("Shouldn't have started a sync!"); + } + Svc.Obs.add("weave:service:sync:start", onSyncStart); + + // Sanity check. + Assert.equal(scheduler.syncTimer, null); + Assert.equal(Status.checkSetup(), STATUS_OK); + Assert.equal(Status.login, LOGIN_SUCCEEDED); + + scheduler.scheduleNextSync(0); + await promiseObserved; + await Async.promiseYield(); + + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR); + + let expectedNextSync = Date.now() + scheduler.syncInterval; + Assert.greater(scheduler.nextSync, Date.now()); + Assert.lessOrEqual(scheduler.nextSync, expectedNextSync); + Assert.greater(scheduler.syncTimer.delay, 0); + Assert.lessOrEqual(scheduler.syncTimer.delay, scheduler.syncInterval); + + Svc.Obs.remove("weave:service:sync:start", onSyncStart); + await cleanUpAndGo(); +}); + +add_task(async function test_loginError_fatal_clearsTriggers() { + _("Verify that a fatal login error clears sync triggers."); + await configureIdentity({ username: "johndoe@mozilla.com" }); + + let server = httpd_setup({ + "/1.1/johndoe@mozilla.com/info/collections": httpd_handler( + 401, + "Unauthorized" + ), + }); + + Service.clusterURL = server.baseURI + "/"; + Status.resetSync(); // reset Status.login + + let promiseObserved = promiseOneObserver("weave:service:login:error"); + + // Sanity check. + Assert.equal(scheduler.nextSync, 0); + Assert.equal(scheduler.syncTimer, null); + Assert.equal(Status.checkSetup(), STATUS_OK); + Assert.equal(Status.login, LOGIN_SUCCEEDED); + + scheduler.scheduleNextSync(0); + await promiseObserved; + await Async.promiseYield(); + + // For the FxA identity, a 401 on info/collections means a transient + // error, probably due to an inability to fetch a token. + Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR); + // syncs should still be scheduled. + Assert.greater(scheduler.nextSync, Date.now()); + Assert.greater(scheduler.syncTimer.delay, 0); + + await cleanUpAndGo(server); +}); + +add_task(async function test_proper_interval_on_only_failing() { + _("Ensure proper behavior when only failed records are applied."); + + // If an engine reports that no records succeeded, we shouldn't decrease the + // sync interval. + Assert.ok(!scheduler.hasIncomingItems); + const INTERVAL = 10000000; + scheduler.syncInterval = INTERVAL; + + Svc.Obs.notify("weave:service:sync:applied", { + applied: 2, + succeeded: 0, + failed: 2, + newFailed: 2, + reconciled: 0, + }); + + await Async.promiseYield(); + scheduler.adjustSyncInterval(); + Assert.ok(!scheduler.hasIncomingItems); + Assert.equal(scheduler.syncInterval, scheduler.singleDeviceInterval); +}); + +add_task(async function test_link_status_change() { + _("Check that we only attempt to sync when link status is up"); + try { + sinon.spy(scheduler, "scheduleNextSync"); + + Svc.Obs.notify("network:link-status-changed", null, "down"); + equal(scheduler.scheduleNextSync.callCount, 0); + + Svc.Obs.notify("network:link-status-changed", null, "change"); + equal(scheduler.scheduleNextSync.callCount, 0); + + Svc.Obs.notify("network:link-status-changed", null, "up"); + equal(scheduler.scheduleNextSync.callCount, 1); + + Svc.Obs.notify("network:link-status-changed", null, "change"); + equal(scheduler.scheduleNextSync.callCount, 1); + } finally { + scheduler.scheduleNextSync.restore(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_tab_engine.js b/src/zen/tests/mochitests/sync/test_tab_engine.js new file mode 100644 index 000000000..7e733627a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_tab_engine.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabProvider } = ChromeUtils.importESModule( + "resource://services-sync/engines/tabs.sys.mjs" +); +const { WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +let engine; +// We'll need the clients engine for testing as tabs is closely related +let clientsEngine; + +async function syncClientsEngine(server) { + clientsEngine._lastFxADevicesFetch = 0; + clientsEngine.lastModified = server.getCollection("foo", "clients").timestamp; + await clientsEngine._sync(); +} + +async function makeRemoteClients() { + let server = await serverForFoo(clientsEngine); + await configureIdentity({ username: "foo" }, server); + await Service.login(); + + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let remoteId = Utils.makeGUID(); + let remoteId2 = Utils.makeGUID(); + let collection = server.getCollection("foo", "clients"); + + _("Create remote client records"); + collection.insertRecord({ + id: remoteId, + name: "Remote client", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteId, + fxaDeviceName: "Fxa - Remote client", + protocols: ["1.5"], + }); + + collection.insertRecord({ + id: remoteId2, + name: "Remote client 2", + type: "desktop", + commands: [], + version: "48", + fxaDeviceId: remoteId2, + fxaDeviceName: "Fxa - Remote client 2", + protocols: ["1.5"], + }); + + let fxAccounts = clientsEngine.fxAccounts; + clientsEngine.fxAccounts = { + notifyDevices() { + return Promise.resolve(true); + }, + device: { + getLocalId() { + return fxAccounts.device.getLocalId(); + }, + getLocalName() { + return fxAccounts.device.getLocalName(); + }, + getLocalType() { + return fxAccounts.device.getLocalType(); + }, + recentDeviceList: [{ id: remoteId, name: "remote device" }], + refreshDeviceList() { + return Promise.resolve(true); + }, + }, + _internal: { + now() { + return Date.now(); + }, + }, + }; + + await syncClientsEngine(server); +} + +add_task(async function setup() { + clientsEngine = Service.clientsEngine; + // Make some clients to test with + await makeRemoteClients(); + + // Make the tabs engine for all the tests to use + engine = Service.engineManager.get("tabs"); + await engine.initialize(); + + // Since these are xpcshell tests, we'll need to mock this + TabProvider.getOrderedNonPrivateWindows = + mockGetOrderedNonPrivateWindows.bind(this, []); +}); + +add_task(async function test_tab_engine_skips_incoming_local_record() { + _("Ensure incoming records that match local client ID are never applied."); + + let localID = clientsEngine.localID; + let collection = new ServerCollection(); + + _("Creating remote tab record with local client ID"); + let localRecord = encryptPayload({ + id: localID, + clientName: "local", + tabs: [ + { + title: "title", + urlHistory: ["http://foo.com/"], + icon: "", + lastUsed: 2000, + }, + ], + }); + collection.insert(localID, localRecord); + + _("Creating remote tab record with a different client ID"); + let remoteID = "fake-guid-00"; // remote should match one of the test clients + let remoteRecord = encryptPayload({ + id: remoteID, + clientName: "not local", + tabs: [ + { + title: "title2", + urlHistory: ["http://bar.com/"], + icon: "", + lastUsed: 3000, + }, + ], + }); + collection.insert(remoteID, remoteRecord); + + _("Setting up Sync server"); + let server = sync_httpd_setup({ + "/1.1/foo/storage/tabs": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { + tabs: { version: engine.version, syncID }, + }; + + await generateNewKeys(Service.collectionKeys); + + let promiseFinished = new Promise(resolve => { + let syncFinish = engine._syncFinish; + engine._syncFinish = async function () { + let remoteTabs = await engine._rustStore.getAll(); + equal( + remoteTabs.length, + 1, + "Remote client record was applied and local wasn't" + ); + let record = remoteTabs[0]; + equal(record.clientId, remoteID, "Remote client ID matches"); + + _("Ensure getAllClients returns the correct shape"); + let clients = await engine.getAllClients(); + equal(clients.length, 1); + let client = clients[0]; + equal(client.id, "fake-guid-00"); + equal(client.name, "Remote client"); + equal(client.type, "desktop"); + Assert.ok(client.lastModified); // lastModified should be filled in once serverModified is populated from the server + Assert.equal(client.tabs.length, 1); + let tab = client.tabs[0]; + Assert.equal(tab.title, "title2"); + Assert.deepEqual(tab.urlHistory, ["http://bar.com/"]); + Assert.equal(tab.icon, ""); + Assert.ok(!tab.inactive); + await syncFinish.call(engine); + resolve(); + }; + }); + + _("Start sync"); + Service.scheduler.hasIncomingItems = false; + await engine._sync(); + await promiseFinished; + // Bug 1800185 - we don't want the sync scheduler to see these records as incoming. + Assert.ok(!Service.scheduler.hasIncomingItems); +}); diff --git a/src/zen/tests/mochitests/sync/test_tab_provider.js b/src/zen/tests/mochitests/sync/test_tab_provider.js new file mode 100644 index 000000000..a9d27f85d --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_tab_provider.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { TabProvider } = ChromeUtils.importESModule( + "resource://services-sync/engines/tabs.sys.mjs" +); + +add_task(async function test_getAllTabs() { + let provider = TabProvider; + + provider.getOrderedNonPrivateWindows = mockGetOrderedNonPrivateWindows.bind( + this, + ["http://bar.com"] + ); + + _("Get all tabs."); + let tabs = ( + await provider.getAllTabsWithEstimatedMax(false, Number.MAX_SAFE_INTEGER) + ).tabs; + _("Tabs: " + JSON.stringify(tabs)); + equal(tabs.length, 1); + equal(tabs[0].title, "title"); + equal(tabs[0].urlHistory.length, 1); + equal(tabs[0].urlHistory[0], "http://bar.com/"); + equal(tabs[0].icon, ""); + equal(tabs[0].lastUsed, 2000); + + _("Get all tabs, and check that filtering works."); + provider.getOrderedNonPrivateWindows = mockGetOrderedNonPrivateWindows.bind( + this, + ["http://foo.com", "about:foo"] + ); + tabs = ( + await provider.getAllTabsWithEstimatedMax(true, Number.MAX_SAFE_INTEGER) + ).tabs; + _("Filtered: " + JSON.stringify(tabs)); + equal(tabs.length, 1); + + _("Get all tabs, and check that they are properly sorted"); + provider.getOrderedNonPrivateWindows = mockGetOrderedNonPrivateWindows.bind( + this, + ["http://foo.com", "http://bar.com"] + ); + tabs = ( + await provider.getAllTabsWithEstimatedMax(true, Number.MAX_SAFE_INTEGER) + ).tabs; + _("Ordered: " + JSON.stringify(tabs)); + equal(tabs[0].lastUsed > tabs[1].lastUsed, true); + + // reader mode URLs are provided. + provider.getOrderedNonPrivateWindows = mockGetOrderedNonPrivateWindows.bind( + this, + ["about:reader?url=http%3A%2F%2Ffoo.com%2F"] + ); + tabs = ( + await provider.getAllTabsWithEstimatedMax(true, Number.MAX_SAFE_INTEGER) + ).tabs; + equal(tabs[0].urlHistory[0], "http://foo.com/"); +}); diff --git a/src/zen/tests/mochitests/sync/test_tab_quickwrite.js b/src/zen/tests/mochitests/sync/test_tab_quickwrite.js new file mode 100644 index 000000000..36eac5a5c --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_tab_quickwrite.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.importESModule("resource://services-sync/engines/tabs.sys.mjs"); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const { TabProvider } = ChromeUtils.importESModule( + "resource://services-sync/engines/tabs.sys.mjs" +); + +const FAR_FUTURE = 4102405200000; // 2100/01/01 + +add_task(async function setup() { + // Since these are xpcshell tests, we'll need to mock ui features + TabProvider.getOrderedNonPrivateWindows = + mockGetOrderedNonPrivateWindows.bind(this, ["http://foo.com"]); +}); + +async function prepareServer() { + _("Setting up Sync server"); + Service.serverConfiguration = { + max_post_records: 100, + }; + + let server = new SyncServer(); + server.start(); + await SyncTestingInfrastructure(server, "username"); + server.registerUser("username"); + + let collection = server.createCollection("username", "tabs"); + await generateNewKeys(Service.collectionKeys); + + let engine = Service.engineManager.get("tabs"); + await engine.initialize(); + + return { server, collection, engine }; +} + +async function withPatchedValue(object, name, patchedVal, fn) { + _(`patching ${name}=${patchedVal}`); + let old = object[name]; + object[name] = patchedVal; + try { + await fn(); + } finally { + object[name] = old; + } +} + +add_task(async function test_tab_quickwrite_works() { + _("Ensure a simple quickWrite works."); + let { server, collection, engine } = await prepareServer(); + Assert.equal(collection.count(), 0, "starting with 0 tab records"); + Assert.ok(await engine.quickWrite()); + // Validate we didn't bork lastSync + let lastSync = await engine.getLastSync(); + Assert.less(lastSync, FAR_FUTURE); + Assert.equal(collection.count(), 1, "tab record was written"); + + await promiseStopServer(server); +}); + +add_task(async function test_tab_bad_status() { + _("Ensure quickWrite silently aborts when we aren't setup correctly."); + let { server, engine } = await prepareServer(); + // Store the original lock to reset it back after this test + let lock = engine.lock; + // Arrange for this test to fail if it tries to take the lock. + engine.lock = function () { + throw new Error("this test should abort syncing before locking"); + }; + let quickWrite = engine.quickWrite.bind(engine); // lol javascript. + + await withPatchedValue(engine, "enabled", false, quickWrite); + await withPatchedValue(Service, "serverConfiguration", null, quickWrite); + + Services.prefs.clearUserPref("services.sync.username"); + await quickWrite(); + // Validate we didn't bork lastSync + let lastSync = await engine.getLastSync(); + Assert.less(lastSync, FAR_FUTURE); + Service.status.resetSync(); + engine.lock = lock; + await promiseStopServer(server); +}); + +add_task(async function test_tab_quickwrite_lock() { + _("Ensure we fail to quickWrite if the engine is locked."); + let { server, collection, engine } = await prepareServer(); + + Assert.equal(collection.count(), 0, "starting with 0 tab records"); + engine.lock(); + Assert.ok(!(await engine.quickWrite())); + Assert.equal(collection.count(), 0, "didn't sync due to being locked"); + engine.unlock(); + + await promiseStopServer(server); +}); + +add_task(async function test_tab_quickwrite_keeps_old_tabs() { + _("Ensure we don't delete other tabs on quickWrite (bug 1801295)."); + let { server, engine } = await prepareServer(); + + // The ID of another device. + const id = "fake-guid-99"; + + // The clients engine is always going to tell us there are no other clients, + // but we need other clients, so we trick it. + // (We can't just stick this on the server, because we treat it as stale because it + // doesn't appear in the fxa list - but tricking it this way works) + let observeClientsFinished = (_subject, _topic, data) => { + if (data == "clients") { + engine.service.clientsEngine.fxAccounts.device._deviceListCache = { + devices: [], + }; + // trick the clients engine into thinking it has a remote client with the same guid. + engine.service.clientsEngine._store._remoteClients = {}; + engine.service.clientsEngine._store._remoteClients[id] = { + id, + fxaDeviceId: id, + }; + } + }; + Services.obs.addObserver(observeClientsFinished, "weave:engine:sync:finish"); + + // need a first sync to ensure everything is setup correctly. + await Service.sync({ engines: ["tabs"] }); + + let remoteRecord = encryptPayload({ + id, + clientName: "not local", + tabs: [ + { + title: "title2", + urlHistory: ["http://bar.com/"], + icon: "", + lastUsed: 3000, + }, + ], + }); + + let collection = server.getCollection("username", "tabs"); + collection.insert(id, remoteRecord); + + // This test can be flakey because sometimes we are so fast we think there is nothing to do. + // Resetting the last sync time here ensures we always fetch records from the server. + engine.setLastSync(0); + await Service.sync({ engines: ["tabs"] }); + + // collection should now have 2 records - ours and the pretend remote one we inserted. + Assert.equal(collection.count(), 2, "starting with 2 tab records"); + + let clients = await engine.getAllClients(); + Assert.equal(clients.length, 1); + + _("Doing a quick-write"); + Assert.ok(await engine.quickWrite()); + + // Should still have our client after a quickWrite. + _("Grabbing clients after the quick-write"); + clients = await engine.getAllClients(); + Assert.equal(clients.length, 1); + + engine.service.clientsEngine._store._remoteClients = {}; + + Services.obs.removeObserver( + observeClientsFinished, + "weave:engine:sync:finish" + ); + + await promiseStopServer(server); +}); + +add_task(async function test_tab_lastSync() { + _("Ensure we restore the lastSync timestamp after a quick-write."); + let { server, collection, engine } = await prepareServer(); + + await engine.initialize(); + await engine.service.clientsEngine.initialize(); + + let origLastSync = engine.lastSync; + Assert.ok(await engine.quickWrite()); + Assert.equal(engine.lastSync, origLastSync); + Assert.equal(collection.count(), 1, "successful sync"); + engine.unlock(); + + await promiseStopServer(server); +}); + +add_task(async function test_tab_quickWrite_telemetry() { + _("Ensure we record the telemetry we expect."); + // hook into telemetry + let telem = get_sync_test_telemetry(); + telem.payloads = []; + let oldSubmit = telem.submit; + let submitPromise = new Promise(resolve => { + telem.submit = function (ping) { + telem.submit = oldSubmit; + resolve(ping); + }; + }); + + let { server, collection, engine } = await prepareServer(); + + Assert.equal(collection.count(), 0, "starting with 0 tab records"); + Assert.ok(await engine.quickWrite()); + Assert.equal(collection.count(), 1, "tab record was written"); + + let ping = await submitPromise; + let syncs = ping.syncs; + Assert.equal(syncs.length, 1); + let sync = syncs[0]; + Assert.equal(sync.why, "quick-write"); + Assert.equal(sync.engines.length, 1); + Assert.equal(sync.engines[0].name, "tabs"); + + await promiseStopServer(server); +}); diff --git a/src/zen/tests/mochitests/sync/test_tab_tracker.js b/src/zen/tests/mochitests/sync/test_tab_tracker.js new file mode 100644 index 000000000..4a924e991 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_tab_tracker.js @@ -0,0 +1,371 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.importESModule("resource://services-sync/engines/tabs.sys.mjs"); +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const { SyncScheduler } = ChromeUtils.importESModule( + "resource://services-sync/policies.sys.mjs" +); + +var scheduler = new SyncScheduler(Service); +let clientsEngine; + +add_task(async function setup() { + await Service.promiseInitialized; + clientsEngine = Service.clientsEngine; + + scheduler.setDefaults(); +}); + +function fakeSvcWinMediator() { + // actions on windows are captured in logs + let logs = []; + delete Services.wm; + + function getNext() { + let elt = { addTopics: [], remTopics: [], numAPL: 0, numRPL: 0 }; + logs.push(elt); + return { + addEventListener(topic) { + elt.addTopics.push(topic); + }, + removeEventListener(topic) { + elt.remTopics.push(topic); + }, + gBrowser: { + addProgressListener() { + elt.numAPL++; + }, + removeProgressListener() { + elt.numRPL++; + }, + }, + }; + } + + Services.wm = { + getEnumerator() { + return [getNext(), getNext()]; + }, + }; + return logs; +} + +function fakeGetTabState(tab) { + return tab; +} + +function clearQuickWriteTimer(tracker) { + if (tracker.tabsQuickWriteTimer) { + tracker.tabsQuickWriteTimer.clear(); + } +} + +add_task(async function run_test() { + let engine = Service.engineManager.get("tabs"); + await engine.initialize(); + _("We assume that tabs have changed at startup."); + let tracker = engine._tracker; + tracker.getTabState = fakeGetTabState; + + Assert.ok(tracker.modified); + Assert.ok( + Utils.deepEquals(Object.keys(await engine.getChangedIDs()), [ + clientsEngine.localID, + ]) + ); + + let logs; + + _("Test listeners are registered on windows"); + logs = fakeSvcWinMediator(); + tracker.start(); + Assert.equal(logs.length, 2); + for (let log of logs) { + Assert.equal(log.addTopics.length, 3); + Assert.ok(log.addTopics.includes("TabOpen")); + Assert.ok(log.addTopics.includes("TabClose")); + Assert.ok(log.addTopics.includes("unload")); + Assert.equal(log.remTopics.length, 0); + Assert.equal(log.numAPL, 1, "Added 1 progress listener"); + Assert.equal(log.numRPL, 0, "Didn't remove a progress listener"); + } + + _("Test listeners are unregistered on windows"); + logs = fakeSvcWinMediator(); + await tracker.stop(); + Assert.equal(logs.length, 2); + for (let log of logs) { + Assert.equal(log.addTopics.length, 0); + Assert.equal(log.remTopics.length, 3); + Assert.ok(log.remTopics.includes("TabOpen")); + Assert.ok(log.remTopics.includes("TabClose")); + Assert.ok(log.remTopics.includes("unload")); + Assert.equal(log.numAPL, 0, "Didn't add a progress listener"); + Assert.equal(log.numRPL, 1, "Removed 1 progress listener"); + } + + _("Test tab listener"); + for (let evttype of ["TabOpen", "TabClose"]) { + // Pretend we just synced. + await tracker.clearChangedIDs(); + Assert.ok(!tracker.modified); + + // Send a fake tab event + tracker.onTab({ + type: evttype, + originalTarget: evttype, + target: { entries: [], currentURI: "about:config" }, + }); + Assert.ok(tracker.modified); + Assert.ok( + Utils.deepEquals(Object.keys(await engine.getChangedIDs()), [ + clientsEngine.localID, + ]) + ); + } + + // Pretend we just synced. + await tracker.clearChangedIDs(); + Assert.ok(!tracker.modified); + + tracker.onTab({ + type: "TabOpen", + originalTarget: "TabOpen", + target: { entries: [], currentURI: "about:config" }, + }); + Assert.ok( + Utils.deepEquals(Object.keys(await engine.getChangedIDs()), [ + clientsEngine.localID, + ]) + ); + + // Pretend we just synced and saw some progress listeners. + await tracker.clearChangedIDs(); + Assert.ok(!tracker.modified); + tracker.onLocationChange({ isTopLevel: false }, undefined, undefined, 0); + Assert.ok(!tracker.modified, "non-toplevel request didn't flag as modified"); + + tracker.onLocationChange( + { isTopLevel: true }, + undefined, + Services.io.newURI("https://www.mozilla.org"), + Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + Assert.ok( + tracker.modified, + "location change within the same document request did flag as modified" + ); + + tracker.onLocationChange( + { isTopLevel: true }, + undefined, + Services.io.newURI("https://www.mozilla.org") + ); + Assert.ok( + tracker.modified, + "location change for a new top-level document flagged as modified" + ); + Assert.ok( + Utils.deepEquals(Object.keys(await engine.getChangedIDs()), [ + clientsEngine.localID, + ]) + ); +}); + +add_task(async function run_sync_on_tab_change_test() { + let testPrefDelay = 20000; + + // This is the pref that determines sync delay after tab change + Svc.PrefBranch.setIntPref( + "syncedTabs.syncDelayAfterTabChange", + testPrefDelay + ); + // We should only be syncing on tab change if + // the user has > 1 client + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.updateClientMode(); + Assert.equal(scheduler.numClients, 2); + + let engine = Service.engineManager.get("tabs"); + + _("We assume that tabs have changed at startup."); + let tracker = engine._tracker; + tracker.getTabState = fakeGetTabState; + + Assert.ok(tracker.modified); + Assert.ok( + Utils.deepEquals(Object.keys(await engine.getChangedIDs()), [ + clientsEngine.localID, + ]) + ); + + _("Test sync is scheduled after a tab change"); + for (let evttype of ["TabOpen", "TabClose"]) { + // Pretend we just synced + await tracker.clearChangedIDs(); + clearQuickWriteTimer(tracker); + + // Send a fake tab event + tracker.onTab({ + type: evttype, + originalTarget: evttype, + target: { entries: [], currentURI: "about:config" }, + }); + // Ensure the tracker fired + Assert.ok(tracker.modified); + // We should be more delayed at or more than what the pref is set at + let nextSchedule = tracker.tabsQuickWriteTimer.delay; + Assert.greaterOrEqual(nextSchedule, testPrefDelay); + } + + _("Test sync is NOT scheduled after an unsupported tab open"); + for (let evttype of ["TabOpen"]) { + // Send a fake tab event + tracker.onTab({ + type: evttype, + originalTarget: evttype, + target: { entries: ["about:newtab"], currentURI: null }, + }); + // Ensure the tracker fired + Assert.ok(tracker.modified); + // We should be scheduling <= pref value + Assert.lessOrEqual(scheduler.nextSync - Date.now(), testPrefDelay); + } + + _("Test navigating within the same tab does NOT trigger a sync"); + // Pretend we just synced + await tracker.clearChangedIDs(); + clearQuickWriteTimer(tracker); + + tracker.onLocationChange( + { isTopLevel: true }, + undefined, + Services.io.newURI("https://www.mozilla.org"), + Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD + ); + Assert.ok( + !tracker.modified, + "location change for reloading doesn't trigger a sync" + ); + Assert.ok(!tracker.tabsQuickWriteTimer, "reload does not trigger a sync"); + + // Pretend we just synced + await tracker.clearChangedIDs(); + clearQuickWriteTimer(tracker); + + _("Test navigating to an about page does trigger sync"); + tracker.onLocationChange( + { isTopLevel: true }, + undefined, + Services.io.newURI("about:config") + ); + Assert.ok(tracker.modified, "about page does not trigger a tab modified"); + Assert.ok( + tracker.tabsQuickWriteTimer, + "about schema should trigger a sync happening soon" + ); + + _("Test adjusting the filterScheme pref works"); + // Pretend we just synced + await tracker.clearChangedIDs(); + clearQuickWriteTimer(tracker); + + Svc.PrefBranch.setStringPref( + "engine.tabs.filteredSchemes", + // Removing the about scheme for this test + "resource|chrome|file|blob|moz-extension" + ); + tracker.onLocationChange( + { isTopLevel: true }, + undefined, + Services.io.newURI("about:config") + ); + Assert.ok( + tracker.modified, + "about page triggers a modified after we changed the pref" + ); + Assert.ok( + tracker.tabsQuickWriteTimer, + "about page should schedule a quickWrite sync soon after we changed the pref" + ); + + _("Test no sync after tab change for accounts with <= 1 clients"); + // Pretend we just synced + await tracker.clearChangedIDs(); + clearQuickWriteTimer(tracker); + // Setting clients to only 1 so we don't sync after a tab change + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 0); + scheduler.updateClientMode(); + Assert.equal(scheduler.numClients, 1); + + tracker.onLocationChange( + { isTopLevel: true }, + undefined, + Services.io.newURI("https://www.mozilla.org") + ); + Assert.ok( + tracker.modified, + "location change for a new top-level document flagged as modified" + ); + Assert.ok( + !tracker.tabsQuickWriteTimer, + "We should NOT be syncing shortly because there is only one client" + ); + + _("Changing the pref adjusts the sync schedule"); + Svc.PrefBranch.setIntPref("syncedTabs.syncDelayAfterTabChange", 10000); // 10seconds + let delayPref = Svc.PrefBranch.getIntPref( + "syncedTabs.syncDelayAfterTabChange" + ); + let evttype = "TabOpen"; + Assert.equal(delayPref, 10000); // ensure our pref is at 10s + // Only have task continuity if we have more than 1 device + Svc.PrefBranch.setIntPref("clients.devices.desktop", 1); + Svc.PrefBranch.setIntPref("clients.devices.mobile", 1); + scheduler.updateClientMode(); + Assert.equal(scheduler.numClients, 2); + clearQuickWriteTimer(tracker); + + // Fire ontab event + tracker.onTab({ + type: evttype, + originalTarget: evttype, + target: { entries: [], currentURI: "about:config" }, + }); + + // Ensure the tracker fired + Assert.ok(tracker.modified); + // We should be scheduling <= preference value + Assert.equal(tracker.tabsQuickWriteTimer.delay, delayPref); + + _("We should not have a sync scheduled if pref is at 0"); + + Svc.PrefBranch.setIntPref("syncedTabs.syncDelayAfterTabChange", 0); + // Pretend we just synced + await tracker.clearChangedIDs(); + clearQuickWriteTimer(tracker); + + // Fire ontab event + evttype = "TabOpen"; + tracker.onTab({ + type: evttype, + originalTarget: evttype, + target: { entries: [], currentURI: "about:config" }, + }); + // Ensure the tracker fired + Assert.ok(tracker.modified); + + // We should NOT be scheduled for a sync soon + Assert.ok(!tracker.tabsQuickWriteTimer); + + scheduler.setDefaults(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_telemetry.js b/src/zen/tests/mochitests/sync/test_telemetry.js new file mode 100644 index 000000000..db61ab285 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_telemetry.js @@ -0,0 +1,1274 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { WBORecord } = ChromeUtils.importESModule( + "resource://services-sync/record.sys.mjs" +); +const { RotaryEngine } = ChromeUtils.importESModule( + "resource://testing-common/services/sync/rotaryengine.sys.mjs" +); +const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const fxAccounts = getFxAccountsSingleton(); + +function SteamStore(engine) { + Store.call(this, "Steam", engine); +} +Object.setPrototypeOf(SteamStore.prototype, Store.prototype); + +function SteamTracker(name, engine) { + LegacyTracker.call(this, name || "Steam", engine); +} +Object.setPrototypeOf(SteamTracker.prototype, LegacyTracker.prototype); + +function SteamEngine(service) { + SyncEngine.call(this, "steam", service); +} + +SteamEngine.prototype = { + _storeObj: SteamStore, + _trackerObj: SteamTracker, + _errToThrow: null, + problemsToReport: null, + async _sync() { + if (this._errToThrow) { + throw this._errToThrow; + } + }, + getValidator() { + return new SteamValidator(); + }, +}; +Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype); + +function BogusEngine(service) { + SyncEngine.call(this, "bogus", service); +} + +BogusEngine.prototype = Object.create(SteamEngine.prototype); + +class SteamValidator { + async canValidate() { + return true; + } + + async validate(engine) { + return { + problems: new SteamValidationProblemData(engine.problemsToReport), + version: 1, + duration: 0, + recordCount: 0, + }; + } +} + +class SteamValidationProblemData { + constructor(problemsToReport = []) { + this.problemsToReport = problemsToReport; + } + + getSummary() { + return this.problemsToReport; + } +} + +async function cleanAndGo(engine, server) { + await engine._tracker.clearChangedIDs(); + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + syncTestLogging(); + Service.recordManager.clearCache(); + await promiseStopServer(server); +} + +add_task(async function setup() { + // Avoid addon manager complaining about not being initialized + await Service.engineManager.unregister("addons"); + await Service.engineManager.unregister("extension-storage"); +}); + +add_task(async function test_basic() { + enableValidationPrefs(); + + let helper = track_collections_helper(); + let upd = helper.with_updated_collection; + + let handlers = { + "/1.1/johndoe/info/collections": helper.handler, + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": upd( + "meta", + new ServerWBO("global").handler() + ), + }; + + let collections = [ + "clients", + "bookmarks", + "forms", + "history", + "passwords", + "prefs", + "tabs", + ]; + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = upd( + coll, + new ServerCollection({}, true).handler() + ); + } + + let server = httpd_setup(handlers); + await configureIdentity({ username: "johndoe" }, server); + + await wait_for_ping(() => Service.sync(), true, true); + + for (const pref of Svc.PrefBranch.getChildList("")) { + Svc.PrefBranch.clearUserPref(pref); + } + await promiseStopServer(server); +}); + +add_task(async function test_processIncoming_error() { + let engine = Service.engineManager.get("bookmarks"); + await engine.initialize(); + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let collection = server.user("foo").collection("bookmarks"); + try { + // Create a bogus record that when synced down will provoke a + // network error which in turn provokes an exception in _processIncoming. + const BOGUS_GUID = "zzzzzzzzzzzz"; + let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!"); + bogus_record.get = function get() { + throw new Error("Sync this!"); + }; + // Make the 10 minutes old so it will only be synced in the toFetch phase. + bogus_record.modified = Date.now() / 1000 - 60 * 10; + await engine.setLastSync(Date.now() / 1000 - 60); + engine.toFetch = new SerializableSet([BOGUS_GUID]); + + let error, pingPayload, fullPing; + try { + await sync_engine_and_validate_telem( + engine, + true, + (errPing, fullErrPing) => { + pingPayload = errPing; + fullPing = fullErrPing; + } + ); + } catch (ex) { + error = ex; + } + ok(!!error); + ok(!!pingPayload); + + equal(fullPing.uid, "f".repeat(32)); // as setup by SyncTestingInfrastructure + deepEqual(pingPayload.failureReason, { + name: "httperror", + code: 500, + }); + + equal(pingPayload.engines.length, 1); + + equal(pingPayload.engines[0].name, "bookmarks-buffered"); + deepEqual(pingPayload.engines[0].failureReason, { + name: "httperror", + code: 500, + }); + } finally { + await store.wipe(); + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_uploading() { + let engine = Service.engineManager.get("bookmarks"); + await engine.initialize(); + let store = engine._store; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + let bmk = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://getfirefox.com/", + title: "Get Firefox!", + }); + + try { + let ping = await sync_engine_and_validate_telem(engine, false); + ok(!!ping); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "bookmarks-buffered"); + ok(!!ping.engines[0].outgoing); + greater(ping.engines[0].outgoing[0].sent, 0); + ok(!ping.engines[0].incoming); + + await PlacesUtils.bookmarks.update({ + guid: bmk.guid, + title: "New Title", + }); + + await store.wipe(); + await engine.resetClient(); + // We don't sync via the service, so don't re-hit info/collections, so + // lastModified remaning at zero breaks things subtly... + engine.lastModified = null; + + ping = await sync_engine_and_validate_telem(engine, false); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "bookmarks-buffered"); + equal(ping.engines[0].outgoing.length, 1); + ok(!!ping.engines[0].incoming); + } finally { + // Clean up. + await store.wipe(); + await cleanAndGo(engine, server); + } +}); + +add_task(async function test_upload_failed() { + let collection = new ServerCollection(); + collection._wbos.flying = new ServerWBO("flying"); + + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + + await SyncTestingInfrastructure(server); + await configureIdentity({ username: "foo" }, server); + + let engine = new RotaryEngine(Service); + engine._store.items = { + flying: "LNER Class A3 4472", + scotsman: "Flying Scotsman", + peppercorn: "Peppercorn Class", + }; + const FLYING_CHANGED = 12345; + const SCOTSMAN_CHANGED = 23456; + const PEPPERCORN_CHANGED = 34567; + await engine._tracker.addChangedID("flying", FLYING_CHANGED); + await engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED); + await engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED); + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + await engine.setLastSync(123); // needs to be non-zero so that tracker is queried + let changes = await engine._tracker.getChangedIDs(); + _( + `test_upload_failed: Rotary tracker contents at first sync: ${JSON.stringify( + changes + )}` + ); + engine.enabled = true; + let ping = await sync_engine_and_validate_telem(engine, true); + ok(!!ping); + equal(ping.engines.length, 1); + equal(ping.engines[0].incoming, null); + deepEqual(ping.engines[0].outgoing, [ + { + sent: 3, + failed: 2, + failedReasons: [ + { name: "scotsman", count: 1 }, + { name: "peppercorn", count: 1 }, + ], + }, + ]); + await engine.setLastSync(123); + + changes = await engine._tracker.getChangedIDs(); + _( + `test_upload_failed: Rotary tracker contents at second sync: ${JSON.stringify( + changes + )}` + ); + ping = await sync_engine_and_validate_telem(engine, true); + ok(!!ping); + equal(ping.engines.length, 1); + deepEqual(ping.engines[0].outgoing, [ + { + sent: 2, + failed: 2, + failedReasons: [ + { name: "scotsman", count: 1 }, + { name: "peppercorn", count: 1 }, + ], + }, + ]); + } finally { + await cleanAndGo(engine, server); + await engine.finalize(); + } +}); + +add_task(async function test_sync_partialUpload() { + let collection = new ServerCollection(); + let server = sync_httpd_setup({ + "/1.1/foo/storage/rotary": collection.handler(), + }); + await SyncTestingInfrastructure(server); + await generateNewKeys(Service.collectionKeys); + + let engine = new RotaryEngine(Service); + await engine.setLastSync(123); + + // Create a bunch of records (and server side handlers) + for (let i = 0; i < 234; i++) { + let id = "record-no-" + i; + engine._store.items[id] = "Record No. " + i; + await engine._tracker.addChangedID(id, i); + // Let two items in the first upload batch fail. + if (i != 23 && i != 42) { + collection.insert(id); + } + } + + let syncID = await engine.resetLocalSyncID(); + let meta_global = Service.recordManager.set( + engine.metaURL, + new WBORecord(engine.metaURL) + ); + meta_global.payload.engines = { rotary: { version: engine.version, syncID } }; + + try { + let changes = await engine._tracker.getChangedIDs(); + _( + `test_sync_partialUpload: Rotary tracker contents at first sync: ${JSON.stringify( + changes + )}` + ); + engine.enabled = true; + let ping = await sync_engine_and_validate_telem(engine, true); + + ok(!!ping); + ok(!ping.failureReason); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "rotary"); + ok(!ping.engines[0].incoming); + ok(!ping.engines[0].failureReason); + deepEqual(ping.engines[0].outgoing, [ + { + sent: 234, + failed: 2, + failedReasons: [ + { name: "record-no-23", count: 1 }, + { name: "record-no-42", count: 1 }, + ], + }, + ]); + collection.post = function () { + throw new Error("Failure"); + }; + + engine._store.items["record-no-1000"] = "Record No. 1000"; + await engine._tracker.addChangedID("record-no-1000", 1000); + collection.insert("record-no-1000", 1000); + + await engine.setLastSync(123); + ping = null; + + changes = await engine._tracker.getChangedIDs(); + _( + `test_sync_partialUpload: Rotary tracker contents at second sync: ${JSON.stringify( + changes + )}` + ); + try { + // should throw + await sync_engine_and_validate_telem( + engine, + true, + errPing => (ping = errPing) + ); + } catch (e) {} + // It would be nice if we had a more descriptive error for this... + let uploadFailureError = { + name: "httperror", + code: 500, + }; + + ok(!!ping); + deepEqual(ping.failureReason, uploadFailureError); + equal(ping.engines.length, 1); + equal(ping.engines[0].name, "rotary"); + deepEqual(ping.engines[0].incoming, { + failed: 1, + failedReasons: [{ name: "No ciphertext: nothing to decrypt?", count: 1 }], + }); + ok(!ping.engines[0].outgoing); + deepEqual(ping.engines[0].failureReason, uploadFailureError); + } finally { + await cleanAndGo(engine, server); + await engine.finalize(); + } +}); + +add_task(async function test_generic_engine_fail() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let e = new Error("generic failure message"); + engine._errToThrow = e; + + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + deepEqual(ping.engines.find(err => err.name === "steam").failureReason, { + name: "unexpectederror", + error: String(e), + }); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_engine_fail_weird_errors() { + enableValidationPrefs(); + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + try { + let msg = "Bad things happened!"; + engine._errToThrow = { message: msg }; + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + deepEqual(ping.engines.find(err => err.name === "steam").failureReason, { + name: "unexpectederror", + error: "Bad things happened!", + }); + }); + let e = { msg }; + engine._errToThrow = e; + await sync_and_validate_telem(ping => { + deepEqual(ping.engines.find(err => err.name === "steam").failureReason, { + name: "unexpectederror", + error: JSON.stringify(e), + }); + }); + } finally { + await cleanAndGo(engine, server); + Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_overrideTelemetryName() { + enableValidationPrefs(["steam"]); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.overrideTelemetryName = "steam-but-better"; + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + + const problemsToReport = [ + { name: "someProblem", count: 123 }, + { name: "anotherProblem", count: 456 }, + ]; + + try { + info("Sync with validation problems"); + engine.problemsToReport = problemsToReport; + await sync_and_validate_telem(ping => { + let enginePing = ping.engines.find(e => e.name === "steam-but-better"); + ok(enginePing); + ok(!ping.engines.find(e => e.name === "steam")); + delete enginePing.validation.took; // can't compare real times. + deepEqual( + enginePing.validation, + { + version: 1, + checked: 0, + problems: problemsToReport, + }, + "Should include validation report with overridden name" + ); + }); + + info("Sync without validation problems"); + engine.problemsToReport = null; + await sync_and_validate_telem(ping => { + let enginePing = ping.engines.find(e => e.name === "steam-but-better"); + ok(enginePing); + ok(!ping.engines.find(e => e.name === "steam")); + ok( + !enginePing.validation, + "Should not include validation report when there are no problems" + ); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_engine_fail_ioerror() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + // create an IOError to re-throw as part of Sync. + try { + // (Note that fakeservices.js has replaced Utils.jsonMove etc, but for + // this test we need the real one so we get real exceptions from the + // filesystem.) + await Utils._real_jsonMove("file-does-not-exist", "anything", {}); + } catch (ex) { + engine._errToThrow = ex; + } + ok(engine._errToThrow, "expecting exception"); + + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_engine_fail_ioerror: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + let failureReason = ping.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + // ensure the profile dir in the exception message has been stripped. + ok( + !failureReason.error.includes(PathUtils.profileDir), + failureReason.error + ); + ok(failureReason.error.includes("[profileDir]"), failureReason.error); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_error_detections() { + let telem = get_sync_test_telemetry(); + + // Non-network NS_ERROR_ codes get their own category. + Assert.deepEqual( + telem.transformError(Components.Exception("", Cr.NS_ERROR_FAILURE)), + { name: "nserror", code: Cr.NS_ERROR_FAILURE } + ); + + // Some NS_ERROR_ code in the "network" module are treated as http errors. + Assert.deepEqual( + telem.transformError(Components.Exception("", Cr.NS_ERROR_UNKNOWN_HOST)), + { name: "httperror", code: Cr.NS_ERROR_UNKNOWN_HOST } + ); + // Some NS_ERROR_ABORT is treated as network by our telemetry. + Assert.deepEqual( + telem.transformError(Components.Exception("", Cr.NS_ERROR_ABORT)), + { name: "httperror", code: Cr.NS_ERROR_ABORT } + ); +}); + +add_task(async function test_clean_urls() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + engine._errToThrow = new TypeError( + "http://www.google .com is not a valid URL." + ); + + try { + const changes = await engine._tracker.getChangedIDs(); + _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`); + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + let failureReason = ping.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + equal(failureReason.error, " is not a valid URL."); + }); + // Handle other errors that include urls. + engine._errToThrow = + "Other error message that includes some:url/foo/bar/ in it."; + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + let failureReason = ping.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + equal( + failureReason.error, + "Other error message that includes in it." + ); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +// Test sanitizing guid-related errors with the pattern of +add_task(async function test_sanitize_bookmarks_guid() { + let { ErrorSanitizer } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" + ); + + for (let [original, expected] of [ + [ + "Can't insert Bookmark into Folder ", + "Can't insert Bookmark into Folder ", + ], + [ + "Merge Error: Item can't contain itself", + "Merge Error: Item can't contain itself", + ], + ]) { + const sanitized = ErrorSanitizer.cleanErrorMessage(original); + Assert.equal(sanitized, expected); + } +}); + +// Test sanitization of some hard-coded error strings. +add_task(async function test_clean_errors() { + let { ErrorSanitizer } = ChromeUtils.importESModule( + "resource://services-sync/telemetry.sys.mjs" + ); + + for (let [message, name, expected] of [ + [ + `Could not open the file at ${PathUtils.join( + PathUtils.profileDir, + "weave", + "addonsreconciler.json" + )} for writing`, + "NotFoundError", + "OS error [File/Path not found] Could not open the file at [profileDir]/weave/addonsreconciler.json for writing", + ], + [ + `Could not get info for the file at ${PathUtils.join( + PathUtils.profileDir, + "weave", + "addonsreconciler.json" + )}`, + "NotAllowedError", + "OS error [Permission denied] Could not get info for the file at [profileDir]/weave/addonsreconciler.json", + ], + ]) { + const error = new DOMException(message, name); + const sanitized = ErrorSanitizer.cleanErrorMessage(message, error); + Assert.equal(sanitized, expected); + } +}); + +// Arrange for a sync to hit a "real" OS error during a sync and make sure it's sanitized. +add_task(async function test_clean_real_os_error() { + enableValidationPrefs(); + + // Simulate a real error. + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let path = PathUtils.join(PathUtils.profileDir, "no", "such", "path.json"); + try { + await IOUtils.readJSON(path); + throw new Error("should fail to read the file"); + } catch (ex) { + engine._errToThrow = ex; + } + + try { + const changes = await engine._tracker.getChangedIDs(); + _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`); + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + let failureReason = ping.engines.find( + e => e.name === "steam" + ).failureReason; + equal(failureReason.name, "unexpectederror"); + equal( + failureReason.error, + "OS error [File/Path not found] Could not open `[profileDir]/no/such/path.json': file does not exist" + ); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_initial_sync_engines() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + // These are the only ones who actually have things to sync at startup. + let telemetryEngineNames = ["clients", "prefs", "tabs", "bookmarks-buffered"]; + let server = await serverForEnginesWithKeys( + { foo: "password" }, + ["bookmarks", "prefs", "tabs"].map(name => Service.engineManager.get(name)) + ); + await SyncTestingInfrastructure(server); + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_initial_sync_engines: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + let ping = await wait_for_ping(() => Service.sync(), true); + + equal(ping.engines.find(e => e.name === "clients").outgoing[0].sent, 1); + equal(ping.engines.find(e => e.name === "tabs").outgoing[0].sent, 1); + + // for the rest we don't care about specifics + for (let e of ping.engines) { + if (!telemetryEngineNames.includes(e.name)) { + continue; + } + greaterOrEqual(e.took, 0); + ok(!!e.outgoing); + equal(e.outgoing.length, 1); + notEqual(e.outgoing[0].sent, undefined); + equal(e.outgoing[0].failed, undefined); + equal(e.outgoing[0].failedReasons, undefined); + } + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_nserror() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + engine._errToThrow = Components.Exception( + "NS_ERROR_UNKNOWN_HOST", + Cr.NS_ERROR_UNKNOWN_HOST + ); + try { + const changes = await engine._tracker.getChangedIDs(); + _(`test_nserror: Steam tracker contents: ${JSON.stringify(changes)}`); + await sync_and_validate_telem(ping => { + deepEqual(ping.status, { + service: SYNC_FAILED_PARTIAL, + sync: LOGIN_FAILED_NETWORK_ERROR, + }); + let enginePing = ping.engines.find(e => e.name === "steam"); + deepEqual(enginePing.failureReason, { + name: "httperror", + code: Cr.NS_ERROR_UNKNOWN_HOST, + }); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_sync_why() { + enableValidationPrefs(); + + await Service.engineManager.register(SteamEngine); + let engine = Service.engineManager.get("steam"); + engine.enabled = true; + let server = await serverForFoo(engine); + await SyncTestingInfrastructure(server); + let e = new Error("generic failure message"); + engine._errToThrow = e; + + try { + const changes = await engine._tracker.getChangedIDs(); + _( + `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify( + changes + )}` + ); + let ping = await wait_for_ping( + () => Service.sync({ why: "user" }), + true, + false + ); + _(JSON.stringify(ping)); + equal(ping.why, "user"); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_discarding() { + enableValidationPrefs(); + + let helper = track_collections_helper(); + let upd = helper.with_updated_collection; + let telem = get_sync_test_telemetry(); + telem.maxPayloadCount = 2; + telem.submissionInterval = Infinity; + let oldSubmit = telem.submit; + + let server; + try { + let handlers = { + "/1.1/johndoe/info/collections": helper.handler, + "/1.1/johndoe/storage/crypto/keys": upd( + "crypto", + new ServerWBO("keys").handler() + ), + "/1.1/johndoe/storage/meta/global": upd( + "meta", + new ServerWBO("global").handler() + ), + }; + + let collections = [ + "clients", + "bookmarks", + "forms", + "history", + "passwords", + "prefs", + "tabs", + ]; + + for (let coll of collections) { + handlers["/1.1/johndoe/storage/" + coll] = upd( + coll, + new ServerCollection({}, true).handler() + ); + } + + server = httpd_setup(handlers); + await configureIdentity({ username: "johndoe" }, server); + telem.submit = p => + ok( + false, + "Submitted telemetry ping when we should not have" + JSON.stringify(p) + ); + + for (let i = 0; i < 5; ++i) { + await Service.sync(); + } + telem.submit = oldSubmit; + telem.submissionInterval = -1; + let ping = await wait_for_ping(() => Service.sync(), true, true); // with this we've synced 6 times + equal(ping.syncs.length, 2); + equal(ping.discarded, 4); + } finally { + telem.maxPayloadCount = 500; + telem.submissionInterval = -1; + telem.submit = oldSubmit; + if (server) { + await promiseStopServer(server); + } + } +}); + +add_task(async function test_no_foreign_engines_in_error_ping() { + enableValidationPrefs(); + + await Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let server = await serverForFoo(engine); + engine._errToThrow = new Error("Oh no!"); + await SyncTestingInfrastructure(server); + try { + await sync_and_validate_telem(ping => { + equal(ping.status.service, SYNC_FAILED_PARTIAL); + ok(ping.engines.every(e => e.name !== "bogus")); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_no_foreign_engines_in_success_ping() { + enableValidationPrefs(); + + await Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + try { + await sync_and_validate_telem(ping => { + ok(ping.engines.every(e => e.name !== "bogus")); + }); + } finally { + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_no_ping_for_self_hosters() { + enableValidationPrefs(); + + let telem = get_sync_test_telemetry(); + let oldSubmit = telem.submit; + + await Service.engineManager.register(BogusEngine); + let engine = Service.engineManager.get("bogus"); + engine.enabled = true; + let server = await serverForFoo(engine); + + await SyncTestingInfrastructure(server); + try { + let submitPromise = new Promise(resolve => { + telem.submit = function () { + let result = oldSubmit.apply(this, arguments); + resolve(result); + }; + }); + await Service.sync(); + let pingSubmitted = await submitPromise; + // The Sync testing infrastructure already sets up a custom token server, + // so we don't need to do anything to simulate a self-hosted user. + ok(!pingSubmitted, "Should not submit ping with custom token server URL"); + } finally { + telem.submit = oldSubmit; + await cleanAndGo(engine, server); + await Service.engineManager.unregister(engine); + } +}); + +add_task(async function test_fxa_device_telem() { + let t = get_sync_test_telemetry(); + let syncEnabled = true; + let oldGetClientsEngineRecords = t.getClientsEngineRecords; + let oldGetFxaDevices = t.getFxaDevices; + let oldSyncIsEnabled = t.syncIsEnabled; + let oldSanitizeFxaDeviceId = t.sanitizeFxaDeviceId; + t.syncIsEnabled = () => syncEnabled; + t.sanitizeFxaDeviceId = id => `So clean: ${id}`; + try { + let keep0 = Utils.makeGUID(); + let keep1 = Utils.makeGUID(); + let keep2 = Utils.makeGUID(); + let curdev = Utils.makeGUID(); + + let keep1Sync = Utils.makeGUID(); + let keep2Sync = Utils.makeGUID(); + let curdevSync = Utils.makeGUID(); + let fxaDevices = [ + { + id: curdev, + isCurrentDevice: true, + lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 1, + pushEndpointExpired: false, + type: "desktop", + name: "current device", + }, + { + id: keep0, + isCurrentDevice: false, + lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 10, + pushEndpointExpired: false, + type: "mobile", + name: "dupe", + }, + // Valid 2 + { + id: keep1, + isCurrentDevice: false, + lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 1, + pushEndpointExpired: false, + type: "desktop", + name: "valid2", + }, + // Valid 3 + { + id: keep2, + isCurrentDevice: false, + lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 5, + pushEndpointExpired: false, + type: "desktop", + name: "valid3", + }, + ]; + let clientInfo = [ + { + id: keep1Sync, + fxaDeviceId: keep1, + os: "Windows 30", + version: "Firefox 1 million", + }, + { + id: keep2Sync, + fxaDeviceId: keep2, + os: "firefox, but an os", + verison: "twelve", + }, + { + id: Utils.makeGUID(), + fxaDeviceId: null, + os: "apparently ios used to keep write these IDs as null.", + version: "Doesn't seem to anymore", + }, + { + id: curdevSync, + fxaDeviceId: curdev, + os: "emacs", + version: "22", + }, + { + id: Utils.makeGUID(), + fxaDeviceId: Utils.makeGUID(), + os: "not part of the fxa device set at all", + version: "foo bar baz", + }, + // keep0 intententionally omitted. + ]; + t.getClientsEngineRecords = () => clientInfo; + let devInfo = t.updateFxaDevices(fxaDevices); + equal(devInfo.deviceID, t.sanitizeFxaDeviceId(curdev)); + for (let d of devInfo.devices) { + ok(d.id.startsWith("So clean:")); + if (d.syncID) { + ok(d.syncID.startsWith("So clean:")); + } + } + equal(devInfo.devices.length, 4); + let k0 = devInfo.devices.find(d => d.id == t.sanitizeFxaDeviceId(keep0)); + let k1 = devInfo.devices.find(d => d.id == t.sanitizeFxaDeviceId(keep1)); + let k2 = devInfo.devices.find(d => d.id == t.sanitizeFxaDeviceId(keep2)); + + deepEqual(k0, { + id: t.sanitizeFxaDeviceId(keep0), + type: "mobile", + os: undefined, + version: undefined, + syncID: undefined, + }); + deepEqual(k1, { + id: t.sanitizeFxaDeviceId(keep1), + type: "desktop", + os: clientInfo[0].os, + version: clientInfo[0].version, + syncID: t.sanitizeFxaDeviceId(keep1Sync), + }); + deepEqual(k2, { + id: t.sanitizeFxaDeviceId(keep2), + type: "desktop", + os: clientInfo[1].os, + version: clientInfo[1].version, + syncID: t.sanitizeFxaDeviceId(keep2Sync), + }); + let newCurId = Utils.makeGUID(); + // Update the ID + fxaDevices[0].id = newCurId; + + let keep3 = Utils.makeGUID(); + fxaDevices.push({ + id: keep3, + isCurrentDevice: false, + lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 1, + pushEndpointExpired: false, + type: "desktop", + name: "valid 4", + }); + devInfo = t.updateFxaDevices(fxaDevices); + + let afterSubmit = [keep0, keep1, keep2, keep3, newCurId] + .map(id => t.sanitizeFxaDeviceId(id)) + .sort(); + deepEqual(devInfo.devices.map(d => d.id).sort(), afterSubmit); + + // Reset this, as our override doesn't check for sync being enabled. + t.sanitizeFxaDeviceId = oldSanitizeFxaDeviceId; + syncEnabled = false; + fxAccounts.telemetry._setHashedUID(false); + devInfo = t.updateFxaDevices(fxaDevices); + equal(devInfo.deviceID, undefined); + equal(devInfo.devices.length, 5); + for (let d of devInfo.devices) { + equal(d.os, undefined); + equal(d.version, undefined); + equal(d.syncID, undefined); + // Type should still be present. + notEqual(d.type, undefined); + } + } finally { + t.getClientsEngineRecords = oldGetClientsEngineRecords; + t.getFxaDevices = oldGetFxaDevices; + t.syncIsEnabled = oldSyncIsEnabled; + t.sanitizeFxaDeviceId = oldSanitizeFxaDeviceId; + } +}); + +add_task(async function test_sanitize_fxa_device_id() { + let t = get_sync_test_telemetry(); + fxAccounts.telemetry._setHashedUID(false); + sinon.stub(t, "syncIsEnabled").callsFake(() => true); + const rawDeviceId = "raw one two three"; + try { + equal(t.sanitizeFxaDeviceId(rawDeviceId), null); + fxAccounts.telemetry._setHashedUID("mock uid"); + const sanitizedDeviceId = t.sanitizeFxaDeviceId(rawDeviceId); + ok(sanitizedDeviceId); + notEqual(sanitizedDeviceId, rawDeviceId); + } finally { + t.syncIsEnabled.restore(); + fxAccounts.telemetry._setHashedUID(false); + } +}); + +add_task(async function test_no_node_type() { + let server = sync_httpd_setup({}); + await configureIdentity(null, server); + + await sync_and_validate_telem(ping => { + Assert.strictEqual(ping.syncNodeType, undefined); + }, true); + await promiseStopServer(server); +}); + +add_task(async function test_node_type() { + Service.identity.logout(); + let server = sync_httpd_setup({}); + await configureIdentity({ node_type: "the-node-type" }, server); + + await sync_and_validate_telem(ping => { + equal(ping.syncNodeType, "the-node-type"); + }, true); + await promiseStopServer(server); +}); + +add_task(async function test_node_type_change() { + let pingPromise = wait_for_pings(2); + + Service.identity.logout(); + let server = sync_httpd_setup({}); + await configureIdentity({ node_type: "first-node-type" }, server); + // Default to submitting each hour - we should still submit on node change. + let telem = get_sync_test_telemetry(); + telem.submissionInterval = 60 * 60 * 1000; + // reset the node type from previous test or our first sync will submit. + telem.lastSyncNodeType = null; + // do 2 syncs with the same node type. + await Service.sync(); + await Service.sync(); + // then another with a different node type. + Service.identity.logout(); + await configureIdentity({ node_type: "second-node-type" }, server); + await Service.sync(); + telem.finish(); + + let pings = await pingPromise; + equal(pings.length, 2); + equal(pings[0].syncs.length, 2, "2 syncs in first ping"); + equal(pings[0].syncNodeType, "first-node-type"); + equal(pings[1].syncs.length, 1, "1 sync in second ping"); + equal(pings[1].syncNodeType, "second-node-type"); + await promiseStopServer(server); +}); + +add_task(async function test_ids() { + let telem = get_sync_test_telemetry(); + Assert.ok(!telem._shouldSubmitForDataChange()); + fxAccounts.telemetry._setHashedUID("new_uid"); + Assert.ok(telem._shouldSubmitForDataChange()); + telem.maybeSubmitForDataChange(); + // now it's been submitted the new uid is current. + Assert.ok(!telem._shouldSubmitForDataChange()); +}); + +add_task(async function test_deletion_request_ping() { + async function assertRecordedSyncDeviceID(expected) { + // The scalar gets updated asynchronously, so wait a tick before checking. + await Promise.resolve(); + const scalars = + Services.telemetry.getSnapshotForScalars("deletion-request").parent || {}; + equal(scalars["deletion.request.sync_device_id"], expected); + } + + const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff"; + const MOCK_DEVICE_ID1 = "ffeeddccbbaa99887766554433221100"; + const MOCK_DEVICE_ID2 = "aabbccddeeff99887766554433221100"; + + // Calculated by hand using SHA256(DEVICE_ID + HASHED_UID)[:32] + const SANITIZED_DEVICE_ID1 = "dd7c845006df9baa1c6d756926519c8c"; + const SANITIZED_DEVICE_ID2 = "0d06919a736fc029007e1786a091882c"; + + let currentDeviceID = null; + sinon.stub(fxAccounts.device, "getLocalId").callsFake(() => { + return Promise.resolve(currentDeviceID); + }); + let telem = get_sync_test_telemetry(); + sinon.stub(telem, "isProductionSyncUser").callsFake(() => true); + fxAccounts.telemetry._setHashedUID(false); + try { + // The scalar should start out undefined, since no user is actually logged in. + await assertRecordedSyncDeviceID(undefined); + + // If we start up without knowing the hashed UID, it should stay undefined. + telem.observe(null, "weave:service:ready"); + await assertRecordedSyncDeviceID(undefined); + + // But now let's say we've discovered the hashed UID from the server. + fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID); + currentDeviceID = MOCK_DEVICE_ID1; + + // Now when we load up, we'll record the sync device id. + telem.observe(null, "weave:service:ready"); + await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID1); + + // When the device-id changes we'll update it. + currentDeviceID = MOCK_DEVICE_ID2; + telem.observe(null, "fxaccounts:new_device_id"); + await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID2); + + // When the user signs out we'll clear it. + telem.observe(null, "fxaccounts:onlogout"); + await assertRecordedSyncDeviceID(""); + } finally { + fxAccounts.telemetry._setHashedUID(false); + telem.isProductionSyncUser.restore(); + fxAccounts.device.getLocalId.restore(); + } +}); diff --git a/src/zen/tests/mochitests/sync/test_tracker_addChanged.js b/src/zen/tests/mochitests/sync/test_tracker_addChanged.js new file mode 100644 index 000000000..3fe74c3fb --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_tracker_addChanged.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function test_tracker_basics() { + let tracker = new LegacyTracker("Tracker", Service); + + let id = "the_id!"; + + _("Make sure nothing exists yet.."); + let changes = await tracker.getChangedIDs(); + Assert.equal(changes[id], null); + + _("Make sure adding of time 0 works"); + await tracker.addChangedID(id, 0); + changes = await tracker.getChangedIDs(); + Assert.equal(changes[id], 0); + + _("A newer time will replace the old 0"); + await tracker.addChangedID(id, 10); + changes = await tracker.getChangedIDs(); + Assert.equal(changes[id], 10); + + _("An older time will not replace the newer 10"); + await tracker.addChangedID(id, 5); + changes = await tracker.getChangedIDs(); + Assert.equal(changes[id], 10); + + _("Adding without time defaults to current time"); + await tracker.addChangedID(id); + changes = await tracker.getChangedIDs(); + Assert.greater(changes[id], 10); +}); + +add_task(async function test_tracker_persistence() { + let tracker = new LegacyTracker("Tracker", Service); + let id = "abcdef"; + + let promiseSave = new Promise((resolve, reject) => { + let save = tracker._storage._save; + tracker._storage._save = function () { + save.call(tracker._storage).then(resolve, reject); + }; + }); + + await tracker.addChangedID(id, 5); + + await promiseSave; + + _("IDs saved."); + const changes = await tracker.getChangedIDs(); + Assert.equal(5, changes[id]); + + let json = await Utils.jsonLoad(["changes", "tracker"], tracker); + Assert.equal(5, json[id]); +}); diff --git a/src/zen/tests/mochitests/sync/test_uistate.js b/src/zen/tests/mochitests/sync/test_uistate.js new file mode 100644 index 000000000..3b0bb384a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_uistate.js @@ -0,0 +1,411 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { UIState } = ChromeUtils.importESModule( + "resource://services-sync/UIState.sys.mjs" +); + +const UIStateInternal = UIState._internal; + +add_task(async function test_isReady_unconfigured() { + UIState.reset(); + + let refreshState = sinon.spy(UIStateInternal, "refreshState"); + + // On the first call, returns false + // Does trigger a refresh of the state - even though services.sync.username + // is undefined we still need to check the account state. + ok(!UIState.isReady()); + // resfreshState is called when idle - so only check after idle. + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(resolve); + }); + ok(refreshState.called); + refreshState.resetHistory(); + + // On subsequent calls, only return true + ok(UIState.isReady()); + ok(!refreshState.called); + + refreshState.restore(); +}); + +add_task(async function test_isReady_signedin() { + UIState.reset(); + Services.prefs.setStringPref("services.sync.username", "foo"); + + let refreshState = sinon.spy(UIStateInternal, "refreshState"); + + // On the first call, returns false and triggers a refresh of the state + ok(!UIState.isReady()); + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(resolve); + }); + ok(refreshState.calledOnce); + refreshState.resetHistory(); + + // On subsequent calls, only return true + ok(UIState.isReady()); + ok(!refreshState.called); + + refreshState.restore(); +}); + +add_task(async function test_refreshState_signedin() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + const now = new Date().toString(); + Services.prefs.setStringPref("services.sync.lastSync", now); + Services.prefs.setStringPref("services.sync.username", "foo@bar.com"); + UIStateInternal.syncing = false; + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ + verified: true, + uid: "123", + email: "foo@bar.com", + displayName: "Foo Bar", + avatar: "https://foo/bar", + }), + hasLocalSession: () => Promise.resolve(true), + keys: { + canGetKeyForScope: () => Promise.resolve(true), + hasKeysForScope: () => Promise.resolve(true), + }, + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_SIGNED_IN); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + equal(state.displayName, "Foo Bar"); + equal(state.avatarURL, "https://foo/bar"); + equal(state.lastSync, now); + equal(state.syncing, false); + equal(state.hasSyncKeys, true); + + UIStateInternal.fxAccounts = fxAccountsOrig; + Services.prefs.clearUserPref("services.sync.username"); +}); + +add_task(async function test_refreshState_syncButNoFxA() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + const now = new Date().toString(); + Services.prefs.setStringPref("services.sync.lastSync", now); + Services.prefs.setStringPref("services.sync.username", "test@test.com"); + UIStateInternal.syncing = false; + + UIStateInternal.fxAccounts = { + getSignedInUser: () => Promise.resolve(null), + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_LOGIN_FAILED); + equal(state.uid, undefined); + equal(state.email, "test@test.com"); + equal(state.displayName, undefined); + equal(state.avatarURL, undefined); + equal(state.lastSync, undefined); // only set when STATUS_SIGNED_IN. + equal(state.syncing, false); + + UIStateInternal.fxAccounts = fxAccountsOrig; + Services.prefs.clearUserPref("services.sync.lastSync"); + Services.prefs.clearUserPref("services.sync.username"); +}); + +add_task(async function test_refreshState_signedin_profile_unavailable() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + const now = new Date().toString(); + Services.prefs.setStringPref("services.sync.lastSync", now); + Services.prefs.setStringPref("services.sync.username", "test@test.com"); + UIStateInternal.syncing = false; + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ verified: true, uid: "123", email: "foo@bar.com" }), + hasLocalSession: () => Promise.resolve(true), + keys: { + canGetKeyForScope: () => Promise.resolve(true), + hasKeysForScope: () => Promise.resolve(true), + }, + _internal: { + profile: { + getProfile: () => { + return Promise.reject(new Error("Profile unavailable")); + }, + }, + }, + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_SIGNED_IN); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + equal(state.displayName, undefined); + equal(state.avatarURL, undefined); + equal(state.lastSync, now); + equal(state.syncing, false); + + UIStateInternal.fxAccounts = fxAccountsOrig; + Services.prefs.clearUserPref("services.sync.lastSync"); + Services.prefs.clearUserPref("services.sync.username"); +}); + +add_task(async function test_refreshState_unverified() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ verified: false, uid: "123", email: "foo@bar.com" }), + hasLocalSession: () => Promise.resolve(true), + keys: { + hasKeysForScope: () => Promise.resolve(true), + }, + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_NOT_VERIFIED); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + equal(state.displayName, undefined); + equal(state.avatarURL, undefined); + equal(state.lastSync, undefined); + + UIStateInternal.fxAccounts = fxAccountsOrig; +}); + +add_task(async function test_refreshState_unverified_nosession() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ verified: false, uid: "123", email: "foo@bar.com" }), + hasLocalSession: () => Promise.resolve(false), + keys: { + hasKeysForScope: () => Promise.resolve(true), + }, + }; + + let state = await UIState.refresh(); + + // No session should "win" over the unverified state. + equal(state.status, UIState.STATUS_LOGIN_FAILED); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + equal(state.displayName, undefined); + equal(state.avatarURL, undefined); + equal(state.lastSync, undefined); + + UIStateInternal.fxAccounts = fxAccountsOrig; +}); + +add_task(async function test_refreshState_loginFailed() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + let loginFailed = sinon.stub(UIStateInternal, "_loginFailed"); + loginFailed.returns(true); + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ verified: true, uid: "123", email: "foo@bar.com" }), + keys: { + canGetKeyForScope: () => Promise.resolve(true), + hasKeysForScope: () => Promise.resolve(true), + }, + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_LOGIN_FAILED); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + equal(state.displayName, undefined); + equal(state.avatarURL, undefined); + equal(state.lastSync, undefined); + + loginFailed.restore(); + UIStateInternal.fxAccounts = fxAccountsOrig; +}); + +add_task(async function test_observer_refreshState() { + let refreshState = sinon.spy(UIStateInternal, "refreshState"); + + let shouldRefresh = [ + "weave:service:login:got-hashed-id", + "weave:service:login:error", + "weave:service:ready", + "fxaccounts:onverified", + "fxaccounts:onlogin", + "fxaccounts:onlogout", + "fxaccounts:profilechange", + ]; + + for (let topic of shouldRefresh) { + let uiUpdateObserved = observeUIUpdate(); + Services.obs.notifyObservers(null, topic); + await uiUpdateObserved; + ok(refreshState.calledOnce); + refreshState.resetHistory(); + } + + refreshState.restore(); +}); + +// Drive the UIState in a configured state. +async function configureUIState(syncing, lastSync = new Date()) { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + UIStateInternal._syncing = syncing; + Services.prefs.setStringPref("services.sync.lastSync", lastSync.toString()); + Services.prefs.setStringPref("services.sync.username", "test@test.com"); + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ verified: true, uid: "123", email: "foo@bar.com" }), + hasLocalSession: () => Promise.resolve(true), + keys: { + canGetKeyForScope: () => Promise.resolve(true), + hasKeysForScope: () => Promise.resolve(true), + }, + }; + await UIState.refresh(); + UIStateInternal.fxAccounts = fxAccountsOrig; +} + +add_task(async function test_syncStarted() { + await configureUIState(false); + + const oldState = Object.assign({}, UIState.get()); + ok(!oldState.syncing); + + let uiUpdateObserved = observeUIUpdate(); + Services.obs.notifyObservers(null, "weave:service:sync:start"); + await uiUpdateObserved; + + const newState = Object.assign({}, UIState.get()); + ok(newState.syncing); +}); + +add_task(async function test_syncFinished() { + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + await configureUIState(true, yesterday); + + const oldState = Object.assign({}, UIState.get()); + ok(oldState.syncing); + + let uiUpdateObserved = observeUIUpdate(); + Services.prefs.setStringPref("services.sync.lastSync", new Date().toString()); + Services.obs.notifyObservers(null, "weave:service:sync:finish"); + await uiUpdateObserved; + + const newState = Object.assign({}, UIState.get()); + ok(!newState.syncing); + Assert.greater(new Date(newState.lastSync), new Date(oldState.lastSync)); +}); + +add_task(async function test_syncError() { + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + await configureUIState(true, yesterday); + + const oldState = Object.assign({}, UIState.get()); + ok(oldState.syncing); + + let uiUpdateObserved = observeUIUpdate(); + Services.obs.notifyObservers(null, "weave:service:sync:error"); + await uiUpdateObserved; + + const newState = Object.assign({}, UIState.get()); + ok(!newState.syncing); + deepEqual(newState.lastSync, oldState.lastSync); +}); + +add_task(async function test_refreshState_signedin_with_synckeys() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + Services.prefs.setStringPref("services.sync.username", "test@test.com"); + + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ + verified: true, + uid: "123", + email: "foo@bar.com", + displayName: "Foo Bar", + }), + hasLocalSession: () => Promise.resolve(true), + keys: { + canGetKeyForScope: () => Promise.resolve(true), + hasKeysForScope: () => Promise.resolve(true), + }, + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_SIGNED_IN); + equal(state.syncEnabled, true); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + + UIStateInternal.fxAccounts = fxAccountsOrig; + Services.prefs.clearUserPref("services.sync.username"); +}); + +// Testing third-party auth does not enable sync (should be impossible without keys) +add_task(async function test_refreshState_third_party_auth_no_sync() { + UIState.reset(); + const fxAccountsOrig = UIStateInternal.fxAccounts; + + // Mock signing in but NO keys (third-party auth) + UIStateInternal.fxAccounts = { + getSignedInUser: () => + Promise.resolve({ + verified: true, + uid: "123", + email: "foo@bar.com", + }), + hasLocalSession: () => Promise.resolve(true), + keys: { + hasKeysForScope: () => Promise.resolve(false), + }, + }; + + let state = await UIState.refresh(); + + equal(state.status, UIState.STATUS_SIGNED_IN); + equal(state.syncEnabled, false); + equal(state.hasSyncKeys, false); + equal(state.uid, "123"); + equal(state.email, "foo@bar.com"); + + UIStateInternal.fxAccounts = fxAccountsOrig; +}); + +function observeUIUpdate() { + return new Promise(resolve => { + let obs = (aSubject, aTopic) => { + Services.obs.removeObserver(obs, aTopic); + const state = UIState.get(); + resolve(state); + }; + Services.obs.addObserver(obs, UIState.ON_UPDATE); + }); +} diff --git a/src/zen/tests/mochitests/sync/test_utils_catch.js b/src/zen/tests/mochitests/sync/test_utils_catch.js new file mode 100644 index 000000000..590d04527 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_catch.js @@ -0,0 +1,119 @@ +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +add_task(async function run_test() { + _("Make sure catch when copied to an object will correctly catch stuff"); + let ret, rightThis, didCall, didThrow, wasCovfefe, wasLocked; + let obj = { + _catch: Utils.catch, + _log: { + debug(str) { + didThrow = str.search(/^Exception/) == 0; + }, + info(str) { + wasLocked = str.indexOf("Cannot start sync: already syncing?") == 0; + }, + }, + + func() { + return this._catch(async function () { + rightThis = this == obj; + didCall = true; + return 5; + })(); + }, + + throwy() { + return this._catch(async function () { + rightThis = this == obj; + didCall = true; + throw new Error("covfefe"); + })(); + }, + + callbacky() { + return this._catch( + async function () { + rightThis = this == obj; + didCall = true; + throw new Error("covfefe"); + }, + async function (ex) { + wasCovfefe = ex && ex.message == "covfefe"; + } + )(); + }, + + lockedy() { + return this._catch(async function () { + rightThis = this == obj; + didCall = true; + Utils.throwLockException(null); + })(); + }, + + lockedy_chained() { + return this._catch(async function () { + rightThis = this == obj; + didCall = true; + Utils.throwLockException(null); + })(); + }, + }; + + _("Make sure a normal call will call and return"); + rightThis = didCall = didThrow = wasLocked = false; + ret = await obj.func(); + Assert.equal(ret, 5); + Assert.ok(rightThis); + Assert.ok(didCall); + Assert.ok(!didThrow); + Assert.equal(wasCovfefe, undefined); + Assert.ok(!wasLocked); + + _( + "Make sure catch/throw results in debug call and caller doesn't need to handle exception" + ); + rightThis = didCall = didThrow = wasLocked = false; + ret = await obj.throwy(); + Assert.equal(ret, undefined); + Assert.ok(rightThis); + Assert.ok(didCall); + Assert.ok(didThrow); + Assert.equal(wasCovfefe, undefined); + Assert.ok(!wasLocked); + + _("Test callback for exception testing."); + rightThis = didCall = didThrow = wasLocked = false; + ret = await obj.callbacky(); + Assert.equal(ret, undefined); + Assert.ok(rightThis); + Assert.ok(didCall); + Assert.ok(didThrow); + Assert.ok(wasCovfefe); + Assert.ok(!wasLocked); + + _("Test the lock-aware catch that Service uses."); + obj._catch = Service._catch; + rightThis = didCall = didThrow = wasLocked = false; + wasCovfefe = undefined; + ret = await obj.lockedy(); + Assert.equal(ret, undefined); + Assert.ok(rightThis); + Assert.ok(didCall); + Assert.ok(didThrow); + Assert.equal(wasCovfefe, undefined); + Assert.ok(wasLocked); + + _("Test the lock-aware catch that Service uses with a chained promise."); + rightThis = didCall = didThrow = wasLocked = false; + wasCovfefe = undefined; + ret = await obj.lockedy_chained(); + Assert.equal(ret, undefined); + Assert.ok(rightThis); + Assert.ok(didCall); + Assert.ok(didThrow); + Assert.equal(wasCovfefe, undefined); + Assert.ok(wasLocked); +}); diff --git a/src/zen/tests/mochitests/sync/test_utils_deepEquals.js b/src/zen/tests/mochitests/sync/test_utils_deepEquals.js new file mode 100644 index 000000000..218cc21b7 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_deepEquals.js @@ -0,0 +1,51 @@ +_("Make sure Utils.deepEquals correctly finds items that are deeply equal"); + +Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_eval_with_system_principal"); +}); + +function run_test() { + let data = + '[NaN, undefined, null, true, false, Infinity, 0, 1, "a", "b", {a: 1}, {a: "a"}, [{a: 1}], [{a: true}], {a: 1, b: 2}, [1, 2], [1, 2, 3]]'; + _("Generating two copies of data:", data); + /* eslint-disable no-eval */ + let d1 = eval(data); + let d2 = eval(data); + /* eslint-enable no-eval */ + + d1.forEach(function (a) { + _("Testing", a, typeof a, JSON.stringify([a])); + let numMatch = 0; + + d2.forEach(function (b) { + if (Utils.deepEquals(a, b)) { + numMatch++; + _("Found a match", b, typeof b, JSON.stringify([b])); + } + }); + + let expect = 1; + if (isNaN(a) && typeof a == "number") { + expect = 0; + _("Checking NaN should result in no matches"); + } + + _("Making sure we found the correct # match:", expect); + _("Actual matches:", numMatch); + Assert.equal(numMatch, expect); + }); + + _("Make sure adding undefined properties doesn't affect equalness"); + let a = {}; + let b = { a: undefined }; + Assert.ok(Utils.deepEquals(a, b)); + a.b = 5; + Assert.ok(!Utils.deepEquals(a, b)); + b.b = 5; + Assert.ok(Utils.deepEquals(a, b)); + a.c = undefined; + Assert.ok(Utils.deepEquals(a, b)); + b.d = undefined; + Assert.ok(Utils.deepEquals(a, b)); +} diff --git a/src/zen/tests/mochitests/sync/test_utils_deferGetSet.js b/src/zen/tests/mochitests/sync/test_utils_deferGetSet.js new file mode 100644 index 000000000..6db812b84 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_deferGetSet.js @@ -0,0 +1,50 @@ +_( + "Make sure various combinations of deferGetSet arguments correctly defer getting/setting properties to another object" +); + +function run_test() { + let base = function () {}; + base.prototype = { + dst: {}, + + get a() { + return "a"; + }, + set b(val) { + this.dst.b = val + "!!!"; + }, + }; + let src = new base(); + + _("get/set a single property"); + Utils.deferGetSet(base, "dst", "foo"); + src.foo = "bar"; + Assert.equal(src.dst.foo, "bar"); + Assert.equal(src.foo, "bar"); + + _("editing the target also updates the source"); + src.dst.foo = "baz"; + Assert.equal(src.dst.foo, "baz"); + Assert.equal(src.foo, "baz"); + + _("handle multiple properties"); + Utils.deferGetSet(base, "dst", ["p1", "p2"]); + src.p1 = "v1"; + src.p2 = "v2"; + Assert.equal(src.p1, "v1"); + Assert.equal(src.dst.p1, "v1"); + Assert.equal(src.p2, "v2"); + Assert.equal(src.dst.p2, "v2"); + + _("make sure existing getter keeps its functionality"); + Utils.deferGetSet(base, "dst", "a"); + src.a = "not a"; + Assert.equal(src.dst.a, "not a"); + Assert.equal(src.a, "a"); + + _("make sure existing setter keeps its functionality"); + Utils.deferGetSet(base, "dst", "b"); + src.b = "b"; + Assert.equal(src.dst.b, "b!!!"); + Assert.equal(src.b, "b!!!"); +} diff --git a/src/zen/tests/mochitests/sync/test_utils_json.js b/src/zen/tests/mochitests/sync/test_utils_json.js new file mode 100644 index 000000000..5bf26b236 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_json.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); + +add_task(async function test_roundtrip() { + _("Do a simple write of an array to json and read"); + await Utils.jsonSave("foo", {}, ["v1", "v2"]); + + let foo = await Utils.jsonLoad("foo", {}); + Assert.equal(typeof foo, "object"); + Assert.equal(foo.length, 2); + Assert.equal(foo[0], "v1"); + Assert.equal(foo[1], "v2"); +}); + +add_task(async function test_string() { + _("Try saving simple strings"); + await Utils.jsonSave("str", {}, "hi"); + + let str = await Utils.jsonLoad("str", {}); + Assert.equal(typeof str, "string"); + Assert.equal(str.length, 2); + Assert.equal(str[0], "h"); + Assert.equal(str[1], "i"); +}); + +add_task(async function test_number() { + _("Try saving a number"); + await Utils.jsonSave("num", {}, 42); + + let num = await Utils.jsonLoad("num", {}); + Assert.equal(typeof num, "number"); + Assert.equal(num, 42); +}); + +add_task(async function test_nonexistent_file() { + _("Try loading a non-existent file."); + let val = await Utils.jsonLoad("non-existent", {}); + Assert.equal(val, undefined); +}); + +add_task(async function test_save_logging() { + _("Verify that writes are logged."); + let trace; + await Utils.jsonSave( + "log", + { + _log: { + trace(msg) { + trace = msg; + }, + }, + }, + "hi" + ); + Assert.ok(!!trace); +}); + +add_task(async function test_load_logging() { + _("Verify that reads and read errors are logged."); + + // Write a file with some invalid JSON + let file = await IOUtils.getFile(PathUtils.profileDir, "weave", "log.json"); + let fos = Cc["@mozilla.org/network/file-output-stream;1"].createInstance( + Ci.nsIFileOutputStream + ); + let flags = + FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | FileUtils.MODE_TRUNCATE; + fos.init(file, flags, FileUtils.PERMS_FILE, fos.DEFER_OPEN); + let stream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance( + Ci.nsIConverterOutputStream + ); + stream.init(fos, "UTF-8"); + stream.writeString("invalid json!"); + stream.close(); + + let trace, debug; + let obj = { + _log: { + trace(msg) { + trace = msg; + }, + debug(msg) { + debug = msg; + }, + }, + }; + let val = await Utils.jsonLoad("log", obj); + Assert.ok(!val); + Assert.ok(!!trace); + Assert.ok(!!debug); +}); diff --git a/src/zen/tests/mochitests/sync/test_utils_keyEncoding.js b/src/zen/tests/mochitests/sync/test_utils_keyEncoding.js new file mode 100644 index 000000000..30a8a4f2a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_keyEncoding.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + Assert.equal( + Utils.encodeKeyBase32("foobarbafoobarba"), + "mzxw6ytb9jrgcztpn5rgc4tcme" + ); + Assert.equal( + Utils.decodeKeyBase32("mzxw6ytb9jrgcztpn5rgc4tcme"), + "foobarbafoobarba" + ); + Assert.equal( + Utils.encodeKeyBase32( + "\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01" + ), + "aeaqcaibaeaqcaibaeaqcaibae" + ); + Assert.equal( + Utils.decodeKeyBase32("aeaqcaibaeaqcaibaeaqcaibae"), + "\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01" + ); +} diff --git a/src/zen/tests/mochitests/sync/test_utils_lock.js b/src/zen/tests/mochitests/sync/test_utils_lock.js new file mode 100644 index 000000000..71d6486ff --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_lock.js @@ -0,0 +1,76 @@ +_("Make sure lock prevents calling with a shared lock"); + +// Utility that we only use here. + +function do_check_begins(thing, startsWith) { + if (!(thing && thing.indexOf && thing.indexOf(startsWith) == 0)) { + do_throw(thing + " doesn't begin with " + startsWith); + } +} + +add_task(async function run_test() { + let ret, rightThis, didCall; + let state, lockState, lockedState, unlockState; + let obj = { + _lock: Utils.lock, + lock() { + lockState = ++state; + if (this._locked) { + lockedState = ++state; + return false; + } + this._locked = true; + return true; + }, + unlock() { + unlockState = ++state; + this._locked = false; + }, + + func() { + return this._lock("Test utils lock", async function () { + rightThis = this == obj; + didCall = true; + return 5; + })(); + }, + + throwy() { + return this._lock("Test utils lock throwy", async function () { + rightThis = this == obj; + didCall = true; + return this.throwy(); + })(); + }, + }; + + _("Make sure a normal call will call and return"); + rightThis = didCall = false; + state = 0; + ret = await obj.func(); + Assert.equal(ret, 5); + Assert.ok(rightThis); + Assert.ok(didCall); + Assert.equal(lockState, 1); + Assert.equal(unlockState, 2); + Assert.equal(state, 2); + + _("Make sure code that calls locked code throws"); + ret = null; + rightThis = didCall = false; + try { + ret = await obj.throwy(); + do_throw("throwy internal call should have thrown!"); + } catch (ex) { + // Should throw an Error, not a string. + do_check_begins(ex.message, "Could not acquire lock"); + } + Assert.equal(ret, null); + Assert.ok(rightThis); + Assert.ok(didCall); + _("Lock should be called twice so state 3 is skipped"); + Assert.equal(lockState, 4); + Assert.equal(lockedState, 5); + Assert.equal(unlockState, 6); + Assert.equal(state, 6); +}); diff --git a/src/zen/tests/mochitests/sync/test_utils_makeGUID.js b/src/zen/tests/mochitests/sync/test_utils_makeGUID.js new file mode 100644 index 000000000..b1104c111 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_makeGUID.js @@ -0,0 +1,44 @@ +const base64url = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + +function run_test() { + _("Make sure makeGUID makes guids of the right length/characters"); + _("Create a bunch of guids to make sure they don't conflict"); + let guids = []; + for (let i = 0; i < 1000; i++) { + let newGuid = Utils.makeGUID(); + _("Generated " + newGuid); + + // Verify that the GUID's length is correct, even when it's URL encoded. + Assert.equal(newGuid.length, 12); + Assert.equal(encodeURIComponent(newGuid).length, 12); + + // Verify that the GUID only contains base64url characters + Assert.ok( + Array.prototype.every.call(newGuid, function (chr) { + return base64url.includes(chr); + }) + ); + + // Verify that Utils.checkGUID() correctly identifies them as valid. + Assert.ok(Utils.checkGUID(newGuid)); + + // Verify uniqueness within our sample of 1000. This could cause random + // failures, but they should be extremely rare. Otherwise we'd have a + // problem with GUID collisions. + Assert.ok( + guids.every(function (g) { + return g != newGuid; + }) + ); + guids.push(newGuid); + } + + _("Make sure checkGUID fails for invalid GUIDs"); + Assert.ok(!Utils.checkGUID(undefined)); + Assert.ok(!Utils.checkGUID(null)); + Assert.ok(!Utils.checkGUID("")); + Assert.ok(!Utils.checkGUID("blergh")); + Assert.ok(!Utils.checkGUID("ThisGUIDisWayTooLong")); + Assert.ok(!Utils.checkGUID("Invalid!!!!!")); +} diff --git a/src/zen/tests/mochitests/sync/test_utils_notify.js b/src/zen/tests/mochitests/sync/test_utils_notify.js new file mode 100644 index 000000000..5c0c3702a --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_notify.js @@ -0,0 +1,97 @@ +_("Make sure notify sends out the right notifications"); +add_task(async function run_test() { + let ret, rightThis, didCall; + let obj = { + notify: Utils.notify("foo:"), + _log: { + trace() {}, + }, + + func() { + return this.notify("bar", "baz", async function () { + rightThis = this == obj; + didCall = true; + return 5; + })(); + }, + + throwy() { + return this.notify("bad", "one", async function () { + rightThis = this == obj; + didCall = true; + throw new Error("covfefe"); + })(); + }, + }; + + let state = 0; + let makeObs = function (topic) { + let obj2 = { + observe(subject, obsTopic, data) { + this.state = ++state; + this.subject = subject; + this.topic = obsTopic; + this.data = data; + }, + }; + + Svc.Obs.add(topic, obj2); + return obj2; + }; + + _("Make sure a normal call will call and return with notifications"); + rightThis = didCall = false; + let fs = makeObs("foo:bar:start"); + let ff = makeObs("foo:bar:finish"); + let fe = makeObs("foo:bar:error"); + ret = await obj.func(); + Assert.equal(ret, 5); + Assert.ok(rightThis); + Assert.ok(didCall); + + Assert.equal(fs.state, 1); + Assert.equal(fs.subject, undefined); + Assert.equal(fs.topic, "foo:bar:start"); + Assert.equal(fs.data, "baz"); + + Assert.equal(ff.state, 2); + Assert.equal(ff.subject, 5); + Assert.equal(ff.topic, "foo:bar:finish"); + Assert.equal(ff.data, "baz"); + + Assert.equal(fe.state, undefined); + Assert.equal(fe.subject, undefined); + Assert.equal(fe.topic, undefined); + Assert.equal(fe.data, undefined); + + _("Make sure a throwy call will call and throw with notifications"); + ret = null; + rightThis = didCall = false; + let ts = makeObs("foo:bad:start"); + let tf = makeObs("foo:bad:finish"); + let te = makeObs("foo:bad:error"); + try { + ret = await obj.throwy(); + do_throw("throwy should have thrown!"); + } catch (ex) { + Assert.equal(ex.message, "covfefe"); + } + Assert.equal(ret, null); + Assert.ok(rightThis); + Assert.ok(didCall); + + Assert.equal(ts.state, 3); + Assert.equal(ts.subject, undefined); + Assert.equal(ts.topic, "foo:bad:start"); + Assert.equal(ts.data, "one"); + + Assert.equal(tf.state, undefined); + Assert.equal(tf.subject, undefined); + Assert.equal(tf.topic, undefined); + Assert.equal(tf.data, undefined); + + Assert.equal(te.state, 4); + Assert.equal(te.subject.message, "covfefe"); + Assert.equal(te.topic, "foo:bad:error"); + Assert.equal(te.data, "one"); +}); diff --git a/src/zen/tests/mochitests/sync/test_utils_passphrase.js b/src/zen/tests/mochitests/sync/test_utils_passphrase.js new file mode 100644 index 000000000..fa5808611 --- /dev/null +++ b/src/zen/tests/mochitests/sync/test_utils_passphrase.js @@ -0,0 +1,45 @@ +/* eslint no-tabs:"off" */ + +function run_test() { + _("Normalize passphrase recognizes hyphens."); + const pp = "26ect2thczm599m2ffqarbicjq"; + const hyphenated = "2-6ect2-thczm-599m2-ffqar-bicjq"; + Assert.equal(Utils.normalizePassphrase(hyphenated), pp); + + _("Skip whitespace."); + Assert.equal( + "aaaaaaaaaaaaaaaaaaaaaaaaaa", + Utils.normalizePassphrase("aaaaaaaaaaaaaaaaaaaaaaaaaa ") + ); + Assert.equal( + "aaaaaaaaaaaaaaaaaaaaaaaaaa", + Utils.normalizePassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + Assert.equal( + "aaaaaaaaaaaaaaaaaaaaaaaaaa", + Utils.normalizePassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa ") + ); + Assert.equal( + "aaaaaaaaaaaaaaaaaaaaaaaaaa", + Utils.normalizePassphrase(" a-aaaaa-aaaaa-aaaaa-aaaaa-aaaaa ") + ); + Assert.ok(Utils.isPassphrase("aaaaaaaaaaaaaaaaaaaaaaaaaa ")); + Assert.ok(Utils.isPassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa")); + Assert.ok(Utils.isPassphrase(" aaaaaaaaaaaaaaaaaaaaaaaaaa ")); + Assert.ok(Utils.isPassphrase(" a-aaaaa-aaaaa-aaaaa-aaaaa-aaaaa ")); + Assert.ok(!Utils.isPassphrase(" -aaaaa-aaaaa-aaaaa-aaaaa-aaaaa ")); + + _("Normalizing 20-char passphrases."); + Assert.equal( + Utils.normalizePassphrase("abcde-abcde-abcde-abcde"), + "abcdeabcdeabcdeabcde" + ); + Assert.equal( + Utils.normalizePassphrase("a-bcde-abcde-abcde-abcde"), + "a-bcde-abcde-abcde-abcde" + ); + Assert.equal( + Utils.normalizePassphrase(" abcde-abcde-abcde-abcde "), + "abcdeabcdeabcdeabcde" + ); +} diff --git a/src/zen/tests/mochitests/sync/xpcshell.toml b/src/zen/tests/mochitests/sync/xpcshell.toml new file mode 100644 index 000000000..5531f4838 --- /dev/null +++ b/src/zen/tests/mochitests/sync/xpcshell.toml @@ -0,0 +1,369 @@ +[DEFAULT] +run-if = [ + "os != 'android'", +] +head = "head_appinfo.js ../../../../services/common/tests/unit/head_helpers.js head_helpers.js head_http_server.js head_errorhandler_common.js" +firefox-appdir = "browser" +prefs = ["identity.fxaccounts.enabled=true", "browser.formfill.enable=true"] +support-files = [ + "addon1-search.json", + "bootstrap1-search.json", + "missing-sourceuri.json", + "missing-xpi-search.json", + "rewrite-search.json", + "systemaddon-search.json", + "!/services/common/tests/unit/head_helpers.js", + "!/toolkit/components/extensions/test/xpcshell/head_sync.js", + "../../../../services/../toolkit/mozapps/extensions/test/xpinstall/amosigned-sha1only.xpi", +] + +# The manifest is roughly ordered from low-level to high-level. When making +# systemic sweeping changes, this makes it easier to identify errors closer to +# the source. + +# Ensure we can import everything. + +["test_412.js"] + +["test_addon_utils.js"] +run-sequentially = ["true"] # Restarts server, can't change pref. +tags = "addons" + +["test_addons_engine.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 +tags = "addons" + +["test_addons_reconciler.js"] +run-if = [ + "appname != 'thunderbird'", +] +tags = "addons" + +["test_addons_store.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 +tags = "addons" + +["test_addons_tracker.js"] +tags = "addons" + +["test_addons_validator.js"] +tags = "addons" + +["test_bookmark_batch_fail.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_decline_undecline.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_engine.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_order.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_places_query_rewriting.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_record.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_store.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bookmark_tracker.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_bridged_engine.js"] + +["test_clients_engine.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +["test_clients_escape.js"] + +["test_collection_getBatched.js"] + +["test_collections_recovery.js"] + +["test_corrupt_keys.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_declined.js"] + +["test_disconnect_shutdown.js"] + +["test_engine.js"] + +["test_engine_abort.js"] + +["test_engine_changes_during_sync.js"] +run-if = [ + "appname != 'thunderbird'", +] +# Rust logins backend lacks the legacy Sync metadata methods +# (getSyncID/setSyncID/getLastSync) the JS PasswordEngine needs; keep on JSON. +prefs = ["signon.storage.rust.enabled=false"] + +["test_enginemanager.js"] + +["test_errorhandler_1.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 +skip-if = [ + "os == 'mac' && os_version == '15.30' && arch == 'aarch64' && opt", # Bug 1864117 +] + +["test_errorhandler_2.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +["test_errorhandler_filelog.js"] + +["test_errorhandler_sync_checkServerError.js"] + +["test_extension_storage_engine.js"] +run-sequentially = ["true"] # extension-storage migration happens only once, and must be tested first. +run-if = [ + "appname != 'thunderbird'", +] + +["test_extension_storage_engine_kinto.js"] +run-sequentially = ["true"] # extension-storage migration happens only once, and must be tested first. +run-if = [ + "appname != 'thunderbird'", +] + +["test_extension_storage_tracker_kinto.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_form_validator.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_forms_store.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_forms_tracker.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_fxa_node_reassignment.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +["test_fxa_service_cluster.js"] +# Finally, we test each engine. + +["test_glean.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_history_engine.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_history_store.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_history_tracker.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_hmac_error.js"] + +["test_httpd_sync_server.js"] +# HTTP layers. + +["test_interval_triggers.js"] + +["test_keys.js"] + +["test_load_modules.js"] +# util contains a bunch of functionality used throughout. + +["test_node_reassignment.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +# The password engine/store/tracker tests drive the JS PasswordEngine, which +# needs the legacy Sync metadata methods (getSyncID/setSyncID/getLastSync) the +# Rust logins backend lacks; keep them on the JSON backend. +["test_password_engine.js"] +prefs = ["signon.storage.rust.enabled=false"] + +["test_password_store.js"] +prefs = ["signon.storage.rust.enabled=false"] + +["test_password_tracker.js"] +prefs = ["signon.storage.rust.enabled=false"] + +["test_password_validator.js"] + +["test_postqueue.js"] +# Synced tabs. + +["test_prefs_engine.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_prefs_store.js"] +run-if = [ + "appname != 'thunderbird'", +] +support-files = ["prefs_test_prefs_store.js"] + +["test_prefs_tracker.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_records_crypto.js"] + +["test_records_wbo.js"] + +["test_resource.js"] + +["test_resource_header.js"] + +["test_resource_ua.js"] +# Generic Sync types. + +["test_rust_password_engine_mp_locked.js"] + +["test_rust_password_engine_selection.js"] + +["test_rust_password_tracker.js"] + +["test_score_triggers.js"] + +["test_service_attributes.js"] +# Bug 752243: Profile cleanup frequently fails + +["test_service_cluster.js"] + +["test_service_detect_upgrade.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_service_login.js"] +# Service.sync() drives the JS PasswordEngine, which needs the legacy Sync +# metadata methods the Rust logins backend lacks; keep on JSON. +prefs = ["signon.storage.rust.enabled=false"] + +["test_service_startOver.js"] + +["test_service_startup.js"] + +["test_service_sync_401.js"] + +["test_service_sync_locked.js"] + +["test_service_sync_remoteSetup.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +["test_service_sync_specified.js"] + +["test_service_sync_updateEnabledEngines.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +["test_service_verifyLogin.js"] + +["test_service_wipeClient.js"] + +["test_service_wipeServer.js"] +# Bug 752243: Profile cleanup frequently fails + +["test_status.js"] + +["test_status_checkSetup.js"] + +["test_sync_auth_manager.js"] +# Engine APIs. + +["test_syncedtabs.js"] + +["test_syncengine.js"] + +["test_syncengine_sync.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 + +["test_syncscheduler.js"] +run-sequentially = ["true"] # Frequent timeouts, bug 1395148 +# Firefox Accounts specific tests + +["test_tab_engine.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_tab_provider.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_tab_quickwrite.js"] +run-if = [ + "appname != 'thunderbird'", + "os == 'mac' && os_version == '15.30' && arch == 'aarch64'", # Bug 1819545 +] + +["test_tab_tracker.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_telemetry.js"] +run-if = [ + "appname != 'thunderbird'", +] + +["test_tracker_addChanged.js"] +# Service semantics. + +["test_uistate.js"] + +["test_utils_catch.js"] + +["test_utils_deepEquals.js"] + +["test_utils_deferGetSet.js"] + +["test_utils_json.js"] + +["test_utils_keyEncoding.js"] + +["test_utils_lock.js"] + +["test_utils_makeGUID.js"] +run-sequentially = ["true"] # Disproportionately slows down full test run, bug 1450316 + +["test_utils_notify.js"] + +["test_utils_passphrase.js"] +# We have a number of other libraries that are pretty much standalone.