diff options
Diffstat (limited to 'toolkit/components/places/tests/sync/test_bookmark_corruption.js')
-rw-r--r-- | toolkit/components/places/tests/sync/test_bookmark_corruption.js | 3290 |
1 files changed, 3290 insertions, 0 deletions
diff --git a/toolkit/components/places/tests/sync/test_bookmark_corruption.js b/toolkit/components/places/tests/sync/test_bookmark_corruption.js new file mode 100644 index 0000000000..5f0b0afeef --- /dev/null +++ b/toolkit/components/places/tests/sync/test_bookmark_corruption.js @@ -0,0 +1,3290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function reparentItem(db, guid, newParentGuid = null) { + await db.execute( + ` + UPDATE moz_bookmarks SET + parent = IFNULL((SELECT id FROM moz_bookmarks + WHERE guid = :newParentGuid), 0) + WHERE guid = :guid`, + { newParentGuid, guid } + ); +} + +async function getCountOfBookmarkRows(db) { + let queryRows = await db.execute("SELECT COUNT(*) FROM moz_bookmarks"); + Assert.equal(queryRows.length, 1); + return queryRows[0].getResultByIndex(0); +} + +add_task(async function test_multiple_parents() { + let buf = await openMirror("multiple_parents"); + let now = Date.now(); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "toolbar", + parentid: "places", + type: "folder", + modified: now / 1000 - 10, + children: ["bookmarkAAAA"], + }, + { + id: "menu", + parentid: "places", + type: "folder", + modified: now / 1000 - 5, + children: ["bookmarkAAAA", "bookmarkBBBB", "bookmarkCCCC"], + }, + { + id: "unfiled", + parentid: "places", + type: "folder", + modified: now / 1000 - 3, + children: ["bookmarkBBBB"], + }, + { + id: "mobile", + parentid: "places", + type: "folder", + modified: now / 1000, + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "toolbar", + type: "bookmark", + title: "A", + modified: now / 1000 - 10, + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "mobile", + type: "bookmark", + title: "B", + modified: now / 1000 - 3, + bmkUri: "http://example.com/b", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkBBBB", + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.mobileGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave items with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, + "bookmarkAAAA", + "bookmarkBBBB", + ]); + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + title: BookmarksToolbarTitle, + children: [], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + mobile: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "mobile", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.mobileGuid), + title: MobileBookmarksTitle, + children: [], + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkAAAA"), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: datesAdded.get("bookmarkBBBB"), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + }); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent (A B) correctly" + ); + + await storeChangesInMirror(buf, changesToUpload); + + let newChangesToUpload = await buf.apply({ + localTimeSeconds: now / 1000, + remoteTimeSeconds: now / 1000, + }); + deepEqual( + newChangesToUpload, + {}, + "Should not upload any changes after updating mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_reupload_replace() { + let buf = await openMirror("reupload_replace"); + + info("Set up mirror"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + title: "A", + url: "http://example.com/a", + }, + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "folderBBBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B", + children: [], + }, + ], + { needsMerge: false } + ); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkAAAA", + "folderBBBBBB", + "queryCCCCCCC", + "queryDDDDDDD", + ], + }, + { + // A has an invalid URL, but exists locally, so we should reupload a valid + // local copy. This discards _all_ remote changes to A. + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A (remote)", + bmkUri: "!@#$%", + }, + { + id: "folderBBBBBB", + parentid: "menu", + type: "folder", + title: "B (remote)", + children: ["bookmarkEEEE"], + }, + { + // E is a bookmark with an invalid URL that doesn't exist locally, so we'll + // delete it. + id: "bookmarkEEEE", + parentid: "folderBBBBBB", + type: "bookmark", + title: "E (remote)", + bmkUri: "!@#$%", + }, + { + // C is a legacy tag query, so we'll rewrite its URL and reupload it. + id: "queryCCCCCCC", + parentid: "menu", + type: "query", + title: "C (remote)", + bmkUri: "place:type=7&folder=999", + folderName: "taggy", + }, + { + // D is a query with an invalid URL, so we'll delete it. + id: "queryDDDDDDD", + parentid: "menu", + type: "query", + title: "D", + bmkUri: "^&*()", + }, + ]); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkAAAA", + "bookmarkEEEE", + "folderBBBBBB", + PlacesUtils.bookmarks.menuGuid, + "queryCCCCCCC", + "queryDDDDDDD", + ], + "Should leave invalid A, E, D; reuploaded C; B, menu with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + "bookmarkAAAA", + ]); + deepEqual(changesToUpload, { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA", "folderBBBBBB", "queryCCCCCCC"], + }, + }, + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkAAAA"), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + folderBBBBBB: { + // B is reuploaded because we deleted its child E. + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "folderBBBBBB", + type: "folder", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: undefined, + title: "B (remote)", + children: [], + }, + }, + queryCCCCCCC: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "queryCCCCCCC", + type: "query", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: undefined, + bmkUri: "place:tag=taggy", + title: "C (remote)", + folderName: "taggy", + }, + }, + queryDDDDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "queryDDDDDDD", + deleted: true, + }, + }, + bookmarkEEEE: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkEEEE", + deleted: true, + }, + }, + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkEEEE", "queryDDDDDDD"], + "Should store local tombstones for (E D)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_corrupt_local_roots() { + let buf = await openMirror("corrupt_roots"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkBBBB", + parentid: "toolbar", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + try { + info("Move local menu into unfiled"); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid + ); + await Assert.rejects( + buf.apply(), + /The Places roots are invalid/, + "Should abort merge if local tree has misparented syncable root" + ); + + info("Move local Places root into toolbar"); + await buf.db.executeTransaction(async function () { + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.rootGuid + ); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.rootGuid, + PlacesUtils.bookmarks.toolbarGuid + ); + }); + await Assert.rejects( + buf.apply(), + /The Places roots are invalid/, + "Should abort merge if local tree has misparented Places root" + ); + } finally { + info("Restore local roots"); + await buf.db.executeTransaction(async function () { + await reparentItem(buf.db, PlacesUtils.bookmarks.rootGuid); + await reparentItem( + buf.db, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.rootGuid + ); + }); + } + + info("Apply remote with restored roots"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + deepEqual(changesToUpload, {}, "Should not reupload any local records"); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent (A B) correctly with restored roots" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_corrupt_remote_roots() { + let buf = await openMirror("corrupt_remote_roots"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: Menu > Unfiled"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["unfiled", "bookmarkAAAA"], + }, + { + id: "unfiled", + parentid: "menu", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "unfiled", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "toolbar", + deleted: true, + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave deleted roots unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + toolbar: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "toolbar", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.toolbarGuid), + title: BookmarksToolbarTitle, + children: [], + }, + }, + }, + "Should reupload invalid roots" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should not corrupt local roots" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_missing_children() { + let buf = await openMirror("missing_childen"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes: A > ([B] C [D E])"); + { + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + ], + }, + { + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [PlacesUtils.bookmarks.menuGuid], + "Should leave menu with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["menu"], + deleted: [], + }, + "Should reupload menu without missing children (B D E)" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + "Menu children should be (C)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + info("Add (B E) to remote"); + { + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkEEEE", + parentid: "menu", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]) + ); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkBBBB", "bookmarkEEEE"], + "Should leave B, E with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "bookmarkEEEE", "menu"], + deleted: [], + }, + "Should reupload menu and restored children" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + ], + }, + "Menu children should be (C B E)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + info("Add D to remote"); + { + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + ]); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkDDDD"], + "Should leave D with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkDDDD", "menu"], + deleted: [], + }, + "Should reupload complete menu" + ); + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D", + url: "http://example.com/d", + }, + ], + }, + "Menu children should be (C B E D)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_new_orphan_without_local_parent() { + let buf = await openMirror("new_orphan_without_local_parent"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // A doesn't exist locally, so we move the bookmarks into "unfiled" without + // reuploading. When the partial uploader returns and uploads A, we'll + // move the bookmarks to the correct folder. + info("Make remote changes: [A] > (B C D)"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B (remote)", + bmkUri: "http://example.com/b-remote", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d-remote", + }, + ]) + ); + + info("Apply remote with (B C D)"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave orphans B, C, D unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD", "unfiled"], + deleted: [], + }, + "Should reupload orphans (B C D)" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.unfiledGuid, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + ], + }, + "Should move (B C D) to unfiled" + ); + + // A is an orphan because we don't have E locally, but we should move + // (B C D) into A. + info("Add [E] > A to remote"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "folderEEEEEE", + type: "folder", + title: "A", + children: ["bookmarkDDDD", "bookmarkCCCC", "bookmarkBBBB"], + }, + ]); + + info("Apply remote with A"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderAAAAAA"], + "Should leave A with new remote structure unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkBBBB", + "bookmarkCCCC", + "bookmarkDDDD", + "folderAAAAAA", + "unfiled", + ], + deleted: [], + }, + "Should reupload A and its children" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.unfiledGuid, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + "Should move (D C B) into A" + ); + + info("Add E to remote"); + await storeRecords(buf, [ + { + id: "folderEEEEEE", + parentid: "menu", + type: "folder", + title: "E", + children: ["folderAAAAAA"], + }, + ]); + + info("Apply remote with E"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["folderEEEEEE"], + "Should leave E with new remote structure unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["folderAAAAAA", "folderEEEEEE", "menu", "unfiled"], + deleted: [], + }, + "Should move E out of unfiled into menu" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "E", + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + ], + }, + "Should move Menu > E > A" + ); + + info("Add Menu > E to remote"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderEEEEEE"], + }, + ]); + + info("Apply remote with menu"); + { + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not reupload after forming complete tree" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "E", + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B (remote)", + url: "http://example.com/b-remote", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should form complete tree after applying E" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_move_into_orphaned() { + let buf = await openMirror("move_into_orphaned"); + + info("Set up mirror: Menu > (A B (C > (D (E > F))))"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + }, + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "C", + children: [ + { + guid: "bookmarkDDDD", + title: "D", + url: "http://example.com/d", + }, + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "E", + children: [ + { + guid: "bookmarkFFFF", + title: "F", + url: "http://example.com/f", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bookmarkBBBB", "folderCCCCCC"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "folderCCCCCC", + parentid: "menu", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "folderEEEEEE"], + }, + { + id: "bookmarkDDDD", + parentid: "folderCCCCCC", + type: "bookmark", + title: "D", + bmkUri: "http://example.com/d", + }, + { + id: "folderEEEEEE", + parentid: "folderCCCCCC", + type: "folder", + title: "E", + children: ["bookmarkFFFF"], + }, + { + id: "bookmarkFFFF", + parentid: "folderEEEEEE", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + ], + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make local changes: delete D, add E > I"); + await PlacesUtils.bookmarks.remove("bookmarkDDDD"); + await PlacesUtils.bookmarks.insert({ + guid: "bookmarkIIII", + parentGuid: "folderEEEEEE", + title: "I (local)", + url: "http://example.com/i", + }); + + // G doesn't exist on the server. + info("Make remote changes: ([G] > A (C > (D H E))), (C > H)"); + await storeRecords( + buf, + shuffle([ + { + id: "bookmarkAAAA", + parentid: "folderGGGGGG", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "folderCCCCCC", + parentid: "folderGGGGGG", + type: "folder", + title: "C", + children: ["bookmarkDDDD", "bookmarkHHHH", "folderEEEEEE"], + }, + { + id: "bookmarkHHHH", + parentid: "folderCCCCCC", + type: "bookmark", + title: "H (remote)", + bmkUri: "http://example.com/h-remote", + }, + ]) + ); + + info("Apply remote"); + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkAAAA", "folderCCCCCC"], + "Should leave orphaned A, C with new remote structure unmerged" + ); + + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [ + "bookmarkAAAA", + "bookmarkIIII", + "folderCCCCCC", + "folderEEEEEE", + "menu", + ], + deleted: ["bookmarkDDDD"], + }, + "Should upload records for (A I C E); tombstone for D" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + // A remains in its original place, since we don't use the `parentid`, + // and we don't have a record for G. + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "B", + url: "http://example.com/b", + }, + { + // C exists on the server, so we take its children and order. D was + // deleted locally, and doesn't exist remotely. C is also a child of + // G, but we don't have a record for it on the server. + guid: "folderCCCCCC", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 2, + title: "C", + children: [ + { + guid: "bookmarkHHHH", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "H (remote)", + url: "http://example.com/h-remote", + }, + { + guid: "folderEEEEEE", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: "E", + children: [ + { + guid: "bookmarkFFFF", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "F", + url: "http://example.com/f", + }, + { + guid: "bookmarkIIII", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "I (local)", + url: "http://example.com/i", + }, + ], + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should treat local tree as canonical if server is missing new parent" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bookmarkDDDD"], + "Should store local tombstone for D" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_new_orphan_with_local_parent() { + let buf = await openMirror("new_orphan_with_local_parent"); + + info("Set up mirror: A > (B D)"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + }, + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkBBBB", "bookmarkEEEE"], + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkEEEE", + parentid: "folderAAAAAA", + type: "bookmark", + title: "E", + bmkUri: "http://example.com/e", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Simulate a partial write by another device that uploaded only B and C. A + // exists locally, so we can move B and C into the correct folder, but not + // the correct positions. + info("Set up remote with orphans: [A] > (C D)"); + await storeRecords(buf, [ + { + id: "bookmarkDDDD", + parentid: "folderAAAAAA", + type: "bookmark", + title: "D (remote)", + bmkUri: "http://example.com/d-remote", + }, + { + id: "bookmarkCCCC", + parentid: "folderAAAAAA", + type: "bookmark", + title: "C (remote)", + bmkUri: "http://example.com/c-remote", + }, + ]); + + info("Apply remote with (C D)"); + { + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bookmarkCCCC", "bookmarkDDDD"], + "Should leave orphaned C, D unmerged" + ); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: ["bookmarkCCCC", "bookmarkDDDD", "folderAAAAAA"], + deleted: [], + }, + "Should reupload orphans (C D) and folder A" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should move (C D) to end of A" + ); + + // The partial uploader returns and uploads A. + info("Add A to remote"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: [ + "bookmarkCCCC", + "bookmarkDDDD", + "bookmarkEEEE", + "bookmarkBBBB", + ], + }, + ]); + + info("Apply remote with A"); + { + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual( + idsToUpload, + { + updated: [], + deleted: [], + }, + "Should not reupload orphan A" + ); + await storeChangesInMirror(buf, changesToUpload); + } + + await assertLocalTree( + "folderAAAAAA", + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C (remote)", + url: "http://example.com/c-remote", + }, + { + guid: "bookmarkDDDD", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "D (remote)", + url: "http://example.com/d-remote", + }, + { + guid: "bookmarkEEEE", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "E", + url: "http://example.com/e", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 3, + title: "B", + url: "http://example.com/b", + }, + ], + }, + "Should update child positions once A exists in mirror" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_tombstone_as_child() { + await PlacesTestUtils.markBookmarksAsSynced(); + + let buf = await openMirror("tombstone_as_child"); + // Setup the mirror such that an incoming folder references a tombstone + // as a child. + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["bookmarkAAAA", "bookmarkTTTT", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "folderAAAAAA", + type: "bookmark", + title: "Bookmark A", + bmkUri: "http://example.com/a", + }, + { + id: "bookmarkBBBB", + parentid: "folderAAAAAA", + type: "bookmark", + title: "Bookmark B", + bmkUri: "http://example.com/b", + }, + { + id: "bookmarkTTTT", + deleted: true, + }, + ]), + { needsMerge: true } + ); + + let changesToUpload = await buf.apply(); + let idsToUpload = inspectChangeRecords(changesToUpload); + deepEqual(idsToUpload.deleted, [], "no new tombstones were created."); + deepEqual(idsToUpload.updated, ["folderAAAAAA"], "parent is re-uploaded"); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "A", + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/a", + index: 0, + title: "Bookmark A", + }, + { + // Note that this was the 3rd child specified on the server record, + // but we we've correctly moved it back to being the second after + // ignoring the tombstone. + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/b", + index: 1, + title: "Bookmark B", + }, + ], + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should have ignored tombstone record" + ); + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual(tombstones, [], "Should not store local tombstones"); + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_non_syncable_items() { + let buf = await openMirror("non_syncable_items"); + + info("Insert local orphaned left pane queries"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + guid: "folderLEFTPQ", + url: "place:folder=SOMETHING", + title: "Some query", + }, + { + guid: "folderLEFTPC", + url: "place:folder=SOMETHING_ELSE", + title: "A query under 'All Bookmarks'", + }, + ], + }); + + info( + "Insert syncable local items (A > B) that exist in non-syncable remote root H" + ); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // A is non-syncable remotely, but B doesn't exist remotely, so we'll + // remove A from the merged structure, and move B to the menu. + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "bookmarkBBBB", + title: "B", + url: "http://example.com/b", + }, + ], + }, + ], + }); + + info("Insert non-syncable local root C and items (C > (D > E) F)"); + await insertLocalRoot({ + guid: "rootCCCCCCCC", + title: "C", + }); + await PlacesUtils.bookmarks.insertTree({ + guid: "rootCCCCCCCC", + children: [ + { + guid: "folderDDDDDD", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "D", + children: [ + { + guid: "bookmarkEEEE", + url: "http://example.com/e", + title: "E", + }, + ], + }, + { + guid: "bookmarkFFFF", + url: "http://example.com/f", + title: "F", + }, + ], + }); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + // H is a non-syncable root that only exists remotely. + id: "rootHHHHHHHH", + type: "folder", + parentid: "places", + title: "H", + children: ["folderAAAAAA"], + }, + { + // A is a folder with children that's non-syncable remotely, and syncable + // locally. We should remove A and its descendants locally, since its parent + // H is known to be non-syncable remotely. + id: "folderAAAAAA", + parentid: "rootHHHHHHHH", + type: "folder", + title: "A", + children: ["bookmarkFFFF", "bookmarkIIII"], + }, + { + // F exists in two different non-syncable folders: C locally, and A + // remotely. + id: "bookmarkFFFF", + parentid: "folderAAAAAA", + type: "bookmark", + title: "F", + bmkUri: "http://example.com/f", + }, + { + id: "bookmarkIIII", + parentid: "folderAAAAAA", + type: "query", + title: "I", + bmkUri: "http://example.com/i", + }, + { + // The complete left pane root. We should remove all left pane queries + // locally, even though they're syncable, since the left pane root is + // known to be non-syncable. + id: "folderLEFTPR", + type: "folder", + parentid: "places", + title: "", + children: ["folderLEFTPQ", "folderLEFTPF"], + }, + { + id: "folderLEFTPQ", + parentid: "folderLEFTPR", + type: "query", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + { + id: "folderLEFTPF", + parentid: "folderLEFTPR", + type: "folder", + title: "All Bookmarks", + children: ["folderLEFTPC"], + }, + { + id: "folderLEFTPC", + parentid: "folderLEFTPF", + type: "query", + title: "A query under 'All Bookmarks'", + bmkUri: "place:folder=SOMETHING_ELSE", + }, + { + // D, J, and G are syncable remotely, but D is non-syncable locally. Since + // J and G don't exist locally, and are syncable remotely, we'll remove D + // from the merged structure, and move J and G to unfiled. + id: "unfiled", + parentid: "places", + type: "folder", + children: ["folderDDDDDD", "bookmarkGGGG"], + }, + { + id: "folderDDDDDD", + parentid: "unfiled", + type: "folder", + title: "D", + children: ["bookmarkJJJJ"], + }, + { + id: "bookmarkJJJJ", + parentid: "folderDDDDDD", + type: "bookmark", + title: "J", + bmkUri: "http://example.com/j", + }, + { + id: "bookmarkGGGG", + parentid: "unfiled", + type: "bookmark", + title: "G", + bmkUri: "http://example.com/g", + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + [ + "bookmarkFFFF", + "bookmarkIIII", + "bookmarkJJJJ", + "folderAAAAAA", + "folderDDDDDD", + "folderLEFTPC", + "folderLEFTPF", + "folderLEFTPQ", + "folderLEFTPR", + PlacesUtils.bookmarks.menuGuid, + "rootHHHHHHHH", + PlacesUtils.bookmarks.unfiledGuid, + ], + "Should leave non-syncable items and roots with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + "bookmarkBBBB", + "bookmarkJJJJ", + ]); + deepEqual( + changesToUpload, + { + folderAAAAAA: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderAAAAAA", + deleted: true, + }, + }, + folderDDDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderDDDDDD", + deleted: true, + }, + }, + folderLEFTPQ: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPQ", + deleted: true, + }, + }, + folderLEFTPC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPC", + deleted: true, + }, + }, + folderLEFTPR: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPR", + deleted: true, + }, + }, + folderLEFTPF: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "folderLEFTPF", + deleted: true, + }, + }, + rootHHHHHHHH: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "rootHHHHHHHH", + deleted: true, + }, + }, + bookmarkFFFF: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkFFFF", + deleted: true, + }, + }, + bookmarkIIII: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkIIII", + deleted: true, + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: datesAdded.get("bookmarkBBBB"), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + bookmarkJJJJ: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkJJJJ", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: undefined, + bmkUri: "http://example.com/j", + title: "J", + }, + }, + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkBBBB"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkJJJJ", "bookmarkGGGG"], + }, + }, + }, + "Should upload new structure and tombstones for non-syncable items" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkJJJJ", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "J", + url: "http://example.com/j", + }, + { + guid: "bookmarkGGGG", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "G", + url: "http://example.com/g", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should exclude non-syncable items from new local structure" + ); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + [ + "bookmarkFFFF", + "bookmarkIIII", + "folderAAAAAA", + "folderDDDDDD", + "folderLEFTPC", + "folderLEFTPF", + "folderLEFTPQ", + "folderLEFTPR", + "rootHHHHHHHH", + ], + "Should store local tombstones for non-syncable items" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// See what happens when a left-pane root and a left-pane query are on the server +add_task(async function test_left_pane_root() { + let buf = await openMirror("lpr"); + + let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid); + + // This test is expected to not touch bookmarks at all, and if it did + // happen to create a new item that's not under our syncable roots, then + // just checking the result of fetchLocalTree wouldn't pick that up - so + // as an additional safety check, count how many bookmark rows exist. + let numRows = await getCountOfBookmarkRows(buf.db); + + // Add a left pane root, a left-pane query and a left-pane folder to the + // mirror, all correctly parented. + // Because we can determine this is a complete tree that's outside our + // syncable trees, we expect none of them to be applied. + await storeRecords( + buf, + shuffle( + [ + { + id: "folderLEFTPR", + type: "folder", + parentid: "places", + title: "", + children: ["folderLEFTPQ", "folderLEFTPF"], + }, + { + id: "folderLEFTPQ", + type: "query", + parentid: "folderLEFTPR", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + { + id: "folderLEFTPF", + type: "folder", + parentid: "folderLEFTPR", + title: "All Bookmarks", + children: ["folderLEFTPC"], + }, + { + id: "folderLEFTPC", + type: "query", + parentid: "folderLEFTPF", + title: "A query under 'All Bookmarks'", + bmkUri: "place:folder=SOMETHING_ELSE", + }, + ], + { needsMerge: true } + ) + ); + + await buf.apply(); + + // should have ignored everything. + await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree); + + // and a check we didn't write *any* items to the places database, even + // outside of our user roots. + Assert.equal(await getCountOfBookmarkRows(buf.db), numRows); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +// See what happens when a left-pane query (without the left-pane root) is on +// the server +add_task(async function test_left_pane_query() { + let buf = await openMirror("lpq"); + + let initialTree = await fetchLocalTree(PlacesUtils.bookmarks.rootGuid); + + // This test is expected to not touch bookmarks at all, and if it did + // happen to create a new item that's not under our syncable roots, then + // just checking the result of fetchLocalTree wouldn't pick that up - so + // as an additional safety check, count how many bookmark rows exist. + let numRows = await getCountOfBookmarkRows(buf.db); + + // Add the left pane root and left-pane folders to the mirror, correctly parented. + // We should not apply it because we made a policy decision to not apply + // orphaned queries (bug 1433182) + await storeRecords( + buf, + [ + { + id: "folderLEFTPQ", + type: "query", + parentid: "folderLEFTPR", + title: "Some query", + bmkUri: "place:folder=SOMETHING", + }, + ], + { needsMerge: true } + ); + + await buf.apply(); + + // should have ignored everything. + await assertLocalTree(PlacesUtils.bookmarks.rootGuid, initialTree); + + // and further check we didn't apply it as mis-rooted. + Assert.equal(await getCountOfBookmarkRows(buf.db), numRows); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_partial_cycle() { + let buf = await openMirror("partial_cycle"); + + info("Set up mirror: Menu > A > B > C"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + guid: "folderAAAAAA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "A", + children: [ + { + guid: "folderBBBBBB", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "B", + children: [ + { + guid: "bookmarkCCCC", + url: "http://example.com/c", + title: "C", + }, + ], + }, + ], + }, + ], + }); + await storeRecords( + buf, + shuffle([ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkCCCC", + parentid: "folderBBBBBB", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + }, + ]), + { needsMerge: false } + ); + await PlacesTestUtils.markBookmarksAsSynced(); + + // Try to create a cycle: move A into B, and B into the menu, but don't upload + // a record for the menu. + info("Make remote changes: A > C"); + await storeRecords(buf, [ + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A (remote)", + children: ["bookmarkCCCC"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B (remote)", + children: ["folderAAAAAA"], + }, + ]); + + await Assert.rejects( + buf.apply(), + /Item <guid: folderBBBBBB> can't contain itself/, + "Should abort merge if remote tree parents form `parentid` cycle" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_complete_cycle() { + let buf = await openMirror("complete_cycle"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + // This test is order-dependent. We shouldn't recurse infinitely, but, + // depending on the order of the records, we might ignore the circular + // subtree because there's nothing linking it back to the rest of the + // tree. + info("Make remote changes: Menu > A > B > C > A"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["folderAAAAAA"], + }, + { + id: "folderAAAAAA", + parentid: "menu", + type: "folder", + title: "A", + children: ["folderBBBBBB"], + }, + { + id: "folderBBBBBB", + parentid: "folderAAAAAA", + type: "folder", + title: "B", + children: ["folderCCCCCC"], + }, + { + id: "folderCCCCCC", + parentid: "folderBBBBBB", + type: "folder", + title: "C", + children: ["folderDDDDDD"], + }, + { + id: "folderDDDDDD", + parentid: "folderCCCCCC", + type: "folder", + title: "D", + children: ["folderAAAAAA"], + }, + ]); + + await Assert.rejects( + buf.apply(), + /Item <guid: folderAAAAAA> can't contain itself/, + "Should abort merge if remote tree parents form cycle through `children`" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_invalid_guid() { + let now = new Date(); + + let buf = await openMirror("invalid_guid"); + + info("Set up empty mirror"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Make remote changes"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkAAAA", "bad!guid~", "bookmarkBBBB"], + }, + { + id: "bookmarkAAAA", + parentid: "menu", + type: "bookmark", + title: "A", + bmkUri: "http://example.com/a", + }, + { + id: "bad!guid~", + parentid: "menu", + type: "bookmark", + title: "C", + bmkUri: "http://example.com/c", + dateAdded: now.getTime(), + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B", + bmkUri: "http://example.com/b", + }, + ]); + + let changesToUpload = await buf.apply(); + deepEqual( + await buf.fetchUnmergedGuids(), + ["bad!guid~", PlacesUtils.bookmarks.menuGuid], + "Should leave bad GUID and menu with new remote structure unmerged" + ); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + ]); + + let recordIdsToUpload = Object.keys(changesToUpload); + let newGuid = recordIdsToUpload.find( + recordId => !["bad!guid~", "menu"].includes(recordId) + ); + + equal( + recordIdsToUpload.length, + 3, + "Should reupload menu, C, and tombstone for bad GUID" + ); + + deepEqual( + changesToUpload["bad!guid~"], + { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bad!guid~", + deleted: true, + }, + }, + "Should upload tombstone for C's invalid GUID" + ); + + deepEqual( + changesToUpload[newGuid], + { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: newGuid, + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: now.getTime(), + bmkUri: "http://example.com/c", + title: "C", + }, + }, + "Should reupload C with new GUID" + ); + + deepEqual( + changesToUpload.menu, + { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA", newGuid, "bookmarkBBBB"], + }, + }, + "Should reupload menu with new child GUID for C" + ); + + await assertLocalTree(PlacesUtils.bookmarks.menuGuid, { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + { + guid: newGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 1, + title: "C", + url: "http://example.com/c", + }, + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 2, + title: "B", + url: "http://example.com/b", + }, + ], + }); + + let tombstones = await PlacesTestUtils.fetchSyncTombstones(); + deepEqual( + tombstones.map(({ guid }) => guid), + ["bad!guid~"], + "Should store local tombstone for C's invalid GUID" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_sync_status_mismatches() { + let dateAdded = new Date(); + + let buf = await openMirror("sync_status_mismatches"); + + info("Ensure mirror is up-to-date with Places"); + let initialChangesToUpload = await buf.apply(); + + deepEqual( + Object.keys(initialChangesToUpload).sort(), + ["menu", "mobile", "toolbar", "unfiled"], + "Should upload roots on first merge" + ); + + await storeChangesInMirror(buf, initialChangesToUpload); + + info("Make local changes"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + source: PlacesUtils.bookmarks.SOURCES.SYNC, + children: [ + { + // A is NORMAL in Places, but doesn't exist in the mirror. + guid: "bookmarkAAAA", + url: "http://example.com/a", + title: "A", + dateAdded, + }, + ], + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + // B is NEW in Places and exists in the mirror. + guid: "bookmarkBBBB", + url: "http://example.com/b", + title: "B", + dateAdded, + }, + ], + }); + + info("Make remote changes"); + await storeRecords( + buf, + [ + { + id: "unfiled", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB"], + }, + { + id: "toolbar", + parentid: "places", + type: "folder", + children: ["bookmarkCCCC"], + }, + { + id: "bookmarkBBBB", + parentid: "unfiled", + type: "bookmark", + bmkUri: "http://example.com/b", + title: "B", + }, + { + // C is flagged as merged in the mirror, but doesn't exist in Places. + id: "bookmarkCCCC", + parentid: "toolbar", + type: "bookmark", + bmkUri: "http://example.com/c", + title: "C", + }, + ], + { needsMerge: false } + ); + + info("Apply mirror"); + let changesToUpload = await buf.apply(); + deepEqual(await buf.fetchUnmergedGuids(), [], "Should merge all items"); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + ]); + deepEqual( + changesToUpload, + { + bookmarkAAAA: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkAAAA", + type: "bookmark", + parentid: "menu", + hasDupe: true, + parentName: BookmarksMenuTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/a", + title: "A", + }, + }, + bookmarkBBBB: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkBBBB", + type: "bookmark", + parentid: "unfiled", + hasDupe: true, + parentName: UnfiledBookmarksTitle, + dateAdded: dateAdded.getTime(), + bmkUri: "http://example.com/b", + title: "B", + }, + }, + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkAAAA"], + }, + }, + unfiled: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "unfiled", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.unfiledGuid), + title: UnfiledBookmarksTitle, + children: ["bookmarkBBBB"], + }, + }, + }, + "Should flag (A B) and their parents for upload" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.rootGuid, + { + guid: PlacesUtils.bookmarks.rootGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: "", + children: [ + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkAAAA", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "A", + url: "http://example.com/a", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 1, + title: BookmarksToolbarTitle, + children: [ + { + guid: "bookmarkCCCC", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "C", + url: "http://example.com/c", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 3, + title: UnfiledBookmarksTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B", + url: "http://example.com/b", + }, + ], + }, + { + guid: PlacesUtils.bookmarks.mobileGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 4, + title: MobileBookmarksTitle, + }, + ], + }, + "Should parent C correctly" + ); + + await storeChangesInMirror(buf, changesToUpload); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); + +add_task(async function test_invalid_local_urls() { + let buf = await openMirror("invalid_local_urls"); + + info("Skip uploading local roots on first merge"); + await PlacesTestUtils.markBookmarksAsSynced(); + + info("Set up local tree"); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + // A has an invalid URL locally and doesn't exist remotely, so we + // should delete it without uploading a tombstone. + guid: "bookmarkAAAA", + title: "A (local)", + url: "http://example.com/a", + }, + { + // B has an invalid URL locally and has a valid URL remotely, so + // we should replace our local copy with the remote one. + guid: "bookmarkBBBB", + title: "B (local)", + url: "http://example.com/b", + }, + { + // C has an invalid URL on both sides, so we should delete it locally + // and upload a tombstone. + guid: "bookmarkCCCC", + title: "A (local)", + url: "http://example.com/c", + }, + ], + }); + + // The public API doesn't let us insert invalid URLs (for good reason!), so + // we update them directly in Places. + info("Invalidate local URLs"); + await buf.db.executeTransaction(async function () { + const invalidURLs = [ + { + guid: "bookmarkAAAA", + invalidURL: "!@#$%", + }, + { + guid: "bookmarkBBBB", + invalidURL: "^&*(", + }, + { + guid: "bookmarkCCCC", + invalidURL: ")-+!@", + }, + ]; + for (let params of invalidURLs) { + await buf.db.execute( + `UPDATE moz_places SET + url = :invalidURL, + url_hash = hash(:invalidURL) + WHERE id = (SELECT fk FROM moz_bookmarks WHERE guid = :guid)`, + params + ); + } + }); + + info("Set up remote tree"); + await storeRecords(buf, [ + { + id: "menu", + parentid: "places", + type: "folder", + children: ["bookmarkBBBB", "bookmarkCCCC", "bookmarkDDDD"], + }, + { + id: "bookmarkBBBB", + parentid: "menu", + type: "bookmark", + title: "B (remote)", + bmkUri: "http://example.com/b", + }, + { + // C should be marked as `VALIDITY_REPLACE` in the mirror database. + id: "bookmarkCCCC", + parentid: "menu", + type: "bookmark", + title: "C (remote)", + bmkUri: ")(*&^", + }, + { + // D has an invalid URL remotely and doesn't exist locally, so we + // should replace it with a tombstone. + id: "bookmarkDDDD", + parentid: "menu", + type: "bookmark", + title: "D (remote)", + bmkUri: "^%$#@", + }, + ]); + + info("Apply mirror"); + let changesToUpload = await buf.apply(); + + let datesAdded = await promiseManyDatesAdded([ + PlacesUtils.bookmarks.menuGuid, + ]); + deepEqual( + changesToUpload, + { + menu: { + tombstone: false, + counter: 1, + synced: false, + cleartext: { + id: "menu", + type: "folder", + parentid: "places", + hasDupe: true, + parentName: "", + dateAdded: datesAdded.get(PlacesUtils.bookmarks.menuGuid), + title: BookmarksMenuTitle, + children: ["bookmarkBBBB"], + }, + }, + bookmarkCCCC: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkCCCC", + deleted: true, + }, + }, + bookmarkDDDD: { + tombstone: true, + counter: 1, + synced: false, + cleartext: { + id: "bookmarkDDDD", + deleted: true, + }, + }, + }, + "Should reupload menu and tombstones for (C D)" + ); + + await assertLocalTree( + PlacesUtils.bookmarks.menuGuid, + { + guid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + index: 0, + title: BookmarksMenuTitle, + children: [ + { + guid: "bookmarkBBBB", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + index: 0, + title: "B (remote)", + url: "http://example.com/b", + }, + ], + }, + "Should replace B with remote and delete (A C)" + ); + + await storeChangesInMirror(buf, changesToUpload); + deepEqual( + await buf.fetchUnmergedGuids(), + [], + "Should flag all items as merged after upload" + ); + + await buf.finalize(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesSyncUtils.bookmarks.reset(); +}); |