/* 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 { Resource } = ChromeUtils.importESModule( "resource://services-sync/resource.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); let ping = await wait_for_ping(() => Service.sync(), true, true); // Check the "os" block - we can't really check specific values, but can // check it smells sane. ok(ping.os, "there is an OS block"); ok("name" in ping.os, "there is an OS name"); ok("version" in ping.os, "there is an OS version"); ok("locale" in ping.os, "there is an OS locale"); 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")); 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 the file at [profileDir]/no/such/path.json" ); }); } 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(engine.name)) { continue; } greaterOrEqual(e.took, 1); 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_submit_interval() { let telem = get_sync_test_telemetry(); let oldSubmit = telem.submit; let numSubmissions = 0; telem.submit = function () { numSubmissions += 1; }; function notify(what, data = null) { Svc.Obs.notify(what, JSON.stringify(data)); } try { // submissionInterval is set such that each sync should submit notify("weave:service:sync:start", { why: "testing" }); notify("weave:service:sync:finish"); Assert.equal(numSubmissions, 1, "should submit this ping due to interval"); // As should each event outside of a sync. Service.recordTelemetryEvent("object", "method"); Assert.equal(numSubmissions, 2); // But events while we are syncing should not. notify("weave:service:sync:start", { why: "testing" }); Service.recordTelemetryEvent("object", "method"); Assert.equal(numSubmissions, 2, "no submission for this event"); notify("weave:service:sync:finish"); Assert.equal(numSubmissions, 3, "was submitted after sync finish"); } finally { telem.submit = oldSubmit; } }); 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_events() { 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.submissionInterval = Infinity; try { let serverTime = Resource.serverTime; Service.recordTelemetryEvent("object", "method", "value", { foo: "bar" }); let ping = await wait_for_ping(() => Service.sync(), true, true); equal(ping.events.length, 1); let [timestamp, category, method, object, value, extra] = ping.events[0]; ok(typeof timestamp == "number" && timestamp > 0); // timestamp. equal(category, "sync"); equal(method, "method"); equal(object, "object"); equal(value, "value"); deepEqual(extra, { foo: "bar", serverTime: String(serverTime) }); ping = await wait_for_ping( () => { // Test with optional values. Service.recordTelemetryEvent("object", "method"); }, false, true ); equal(ping.events.length, 1); equal(ping.events[0].length, 4); ping = await wait_for_ping( () => { Service.recordTelemetryEvent("object", "method", "extra"); }, false, true ); equal(ping.events.length, 1); equal(ping.events[0].length, 5); ping = await wait_for_ping( () => { Service.recordTelemetryEvent("object", "method", undefined, { foo: "bar", }); }, false, true ); equal(ping.events.length, 1); equal(ping.events[0].length, 6); [timestamp, category, method, object, value, extra] = ping.events[0]; equal(value, null); // Fake a submission due to shutdown. ping = await wait_for_ping( () => { telem.submissionInterval = Infinity; Service.recordTelemetryEvent("object", "method", undefined, { foo: "bar", }); telem.finish("shutdown"); }, false, true ); equal(ping.syncs.length, 0); equal(ping.events.length, 1); equal(ping.events[0].length, 6); } finally { await cleanAndGo(engine, server); await Service.engineManager.unregister(engine); } }); add_task(async function test_histograms() { enableValidationPrefs(); await Service.engineManager.register(BogusEngine); let engine = Service.engineManager.get("bogus"); engine.enabled = true; let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); try { let histId = "TELEMETRY_TEST_LINEAR"; Services.obs.notifyObservers(null, "weave:telemetry:histogram", histId); let ping = await wait_for_ping(() => Service.sync(), true, true); equal(Object.keys(ping.histograms).length, 1); equal(ping.histograms[histId].sum, 0); equal(ping.histograms[histId].histogram_type, 1); } finally { await cleanAndGo(engine, server); await Service.engineManager.unregister(engine); } }); add_task(async function test_invalid_events() { enableValidationPrefs(); await Service.engineManager.register(BogusEngine); let engine = Service.engineManager.get("bogus"); engine.enabled = true; let server = await serverForFoo(engine); async function checkNotRecorded(...args) { Service.recordTelemetryEvent.call(args); let ping = await wait_for_ping(() => Service.sync(), false, true); equal(ping.events, undefined); } await SyncTestingInfrastructure(server); try { let long21 = "l".repeat(21); let long81 = "l".repeat(81); let long86 = "l".repeat(86); await checkNotRecorded("object"); await checkNotRecorded("object", 2); await checkNotRecorded(2, "method"); await checkNotRecorded("object", "method", 2); await checkNotRecorded("object", "method", "value", 2); await checkNotRecorded("object", "method", "value", { foo: 2 }); await checkNotRecorded(long21, "method", "value"); await checkNotRecorded("object", long21, "value"); await checkNotRecorded("object", "method", long81); let badextra = {}; badextra[long21] = "x"; await checkNotRecorded("object", "method", "value", badextra); badextra = { x: long86 }; await checkNotRecorded("object", "method", "value", badextra); for (let i = 0; i < 10; i++) { badextra["name" + i] = "x"; } await checkNotRecorded("object", "method", "value", badextra); } 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 => { ok(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(); } });