/* 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" ); var recordedEvents = []; function checkRecordedEvents(object, expected, message) { // Ignore event telemetry from the merger. let checkEvents = recordedEvents.filter(event => event.object == object); deepEqual(checkEvents, expected, message); // and clear the list so future checks are easier to write. recordedEvents = []; } 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_task(async function setup() { await generateNewKeys(Service.collectionKeys); await Service.engineManager.unregister("bookmarks"); Service.recordTelemetryEvent = (object, method, value, extra = undefined) => { recordedEvents.push({ object, method, value, extra }); }; }); 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 ); checkRecordedEvents( "maintenance", [ { object: "maintenance", method: "run", value: "bookmarks", extra: undefined, }, ], "Should record event for first maintenance run" ); _("Sync again, but ensure maintenance doesn't run"); await Assert.rejects( sync_engine_and_validate_telem(engine, true), ex => ex == syncError ); checkRecordedEvents( "maintenance", [], "Should not record event if maintenance didn't run" ); _("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 ); checkRecordedEvents( "maintenance", [ { object: "maintenance", method: "run", value: "bookmarks", extra: undefined, }, ], "Should record event for second maintenance run" ); _("Fix sync failure; ensure we report success after maintenance"); engine._syncStartup = syncStartup; await sync_engine_and_validate_telem(engine, false); checkRecordedEvents( "maintenance", [ { object: "maintenance", method: "fix", value: "bookmarks", extra: undefined, }, ], "Should record event for successful sync after second maintenance" ); await sync_engine_and_validate_telem(engine, false); checkRecordedEvents( "maintenance", [], "Should not record maintenance events after successful sync" ); } 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. ok(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); } });