/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; /** * This file tests the DirectoryLinksProvider singleton in the DirectoryLinksProvider.jsm module. */ const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu, Constructor: CC } = Components; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource:///modules/DirectoryLinksProvider.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://gre/modules/Http.jsm"); Cu.import("resource://testing-common/httpd.js"); Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); do_get_profile(); const DIRECTORY_LINKS_FILE = "directoryLinks.json"; const DIRECTORY_FRECENCY = 1000; const kURLData = {"en-US": [{"url":"http://example.com","title":"LocalSource"}]}; const kTestURL = 'data:application/json,' + JSON.stringify(kURLData); // DirectoryLinksProvider preferences const kLocalePref = DirectoryLinksProvider._observedPrefs.prefSelectedLocale; const kSourceUrlPref = DirectoryLinksProvider._observedPrefs.linksURL; const kPingUrlPref = "browser.newtabpage.directory.ping"; const kNewtabEnhancedPref = "browser.newtabpage.enhanced"; // httpd settings var server; const kDefaultServerPort = 9000; const kBaseUrl = "http://localhost:" + kDefaultServerPort; const kExamplePath = "/exampleTest/"; const kFailPath = "/fail/"; const kPingPath = "/ping/"; const kExampleURL = kBaseUrl + kExamplePath; const kFailURL = kBaseUrl + kFailPath; const kPingUrl = kBaseUrl + kPingPath; // app/profile/firefox.js are not avaialble in xpcshell: hence, preset them Services.prefs.setCharPref(kLocalePref, "en-US"); Services.prefs.setCharPref(kSourceUrlPref, kTestURL); Services.prefs.setCharPref(kPingUrlPref, kPingUrl); Services.prefs.setBoolPref(kNewtabEnhancedPref, true); const kHttpHandlerData = {}; kHttpHandlerData[kExamplePath] = {"en-US": [{"url":"http://example.com","title":"RemoteSource"}]}; const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", "nsIBinaryInputStream", "setInputStream"); let gLastRequestPath; function getHttpHandler(path) { let code = 200; let body = JSON.stringify(kHttpHandlerData[path]); if (path == kFailPath) { code = 204; } return function(aRequest, aResponse) { gLastRequestPath = aRequest.path; aResponse.setStatusLine(null, code); aResponse.setHeader("Content-Type", "application/json"); aResponse.write(body); }; } function isIdentical(actual, expected) { if (expected == null) { do_check_eq(actual, expected); } else if (typeof expected == "object") { // Make sure all the keys match up do_check_eq(Object.keys(actual).sort() + "", Object.keys(expected).sort()); // Recursively check each value individually Object.keys(expected).forEach(key => { isIdentical(actual[key], expected[key]); }); } else { do_check_eq(actual, expected); } } function fetchData() { let deferred = Promise.defer(); DirectoryLinksProvider.getLinks(linkData => { deferred.resolve(linkData); }); return deferred.promise; } function readJsonFile(jsonFile = DIRECTORY_LINKS_FILE) { let decoder = new TextDecoder(); let directoryLinksFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, jsonFile); return OS.File.read(directoryLinksFilePath).then(array => { let json = decoder.decode(array); return JSON.parse(json); }, () => { return "" }); } function cleanJsonFile(jsonFile = DIRECTORY_LINKS_FILE) { let directoryLinksFilePath = OS.Path.join(OS.Constants.Path.localProfileDir, jsonFile); return OS.File.remove(directoryLinksFilePath); } function LinksChangeObserver() { this.deferred = Promise.defer(); this.onManyLinksChanged = () => this.deferred.resolve(); this.onDownloadFail = this.onManyLinksChanged; } function promiseDirectoryDownloadOnPrefChange(pref, newValue) { let oldValue = Services.prefs.getCharPref(pref); if (oldValue != newValue) { // if the preference value is already equal to newValue // the pref service will not call our observer and we // deadlock. Hence only setup observer if values differ let observer = new LinksChangeObserver(); DirectoryLinksProvider.addObserver(observer); Services.prefs.setCharPref(pref, newValue); return observer.deferred.promise.then(() => { DirectoryLinksProvider.removeObserver(observer); }); } return Promise.resolve(); } function promiseSetupDirectoryLinksProvider(options = {}) { return Task.spawn(function() { let linksURL = options.linksURL || kTestURL; yield DirectoryLinksProvider.init(); yield promiseDirectoryDownloadOnPrefChange(kLocalePref, options.locale || "en-US"); yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, linksURL); do_check_eq(DirectoryLinksProvider._linksURL, linksURL); DirectoryLinksProvider._lastDownloadMS = options.lastDownloadMS || 0; }); } function promiseCleanDirectoryLinksProvider() { return Task.spawn(function() { yield promiseDirectoryDownloadOnPrefChange(kLocalePref, "en-US"); yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kTestURL); DirectoryLinksProvider._lastDownloadMS = 0; DirectoryLinksProvider.reset(); }); } function run_test() { // Set up a mock HTTP server to serve a directory page server = new HttpServer(); server.registerPrefixHandler(kExamplePath, getHttpHandler(kExamplePath)); server.registerPrefixHandler(kFailPath, getHttpHandler(kFailPath)); server.start(kDefaultServerPort); run_next_test(); // Teardown. do_register_cleanup(function() { server.stop(function() { }); DirectoryLinksProvider.reset(); Services.prefs.clearUserPref(kLocalePref); Services.prefs.clearUserPref(kSourceUrlPref); Services.prefs.clearUserPref(kPingUrlPref); Services.prefs.clearUserPref(kNewtabEnhancedPref); }); } add_task(function test_reportSitesAction() { yield DirectoryLinksProvider.init(); let deferred, expectedPath, expectedPost; let done = false; server.registerPrefixHandler(kPingPath, (aRequest, aResponse) => { if (done) { return; } do_check_eq(aRequest.path, expectedPath); let bodyStream = new BinaryInputStream(aRequest.bodyInputStream); let bodyObject = JSON.parse(NetUtil.readInputStreamToString(bodyStream, bodyStream.available())); isIdentical(bodyObject, expectedPost); deferred.resolve(); }); function sendPingAndTest(path, action, index) { deferred = Promise.defer(); expectedPath = kPingPath + path; DirectoryLinksProvider.reportSitesAction(sites, action, index); return deferred.promise; } // Start with a single pinned link at position 3 let sites = [,,{ isPinned: _ => true, link: { directoryId: 1, frecency: 30000, url: "http://directory1/", }, }]; // Make sure we get the click ping for the directory link with fields we want // and unwanted fields removed by stringify/parse expectedPost = JSON.parse(JSON.stringify({ click: 0, locale: "en-US", tiles: [{ id: 1, pin: 1, pos: 2, score: 3, url: undefined, }], })); yield sendPingAndTest("click", "click", 2); // Try a pin click ping delete expectedPost.click; expectedPost.pin = 0; yield sendPingAndTest("click", "pin", 2); // Try a block click ping delete expectedPost.pin; expectedPost.block = 0; yield sendPingAndTest("click", "block", 2); // A view ping has no actions delete expectedPost.block; expectedPost.view = 0; yield sendPingAndTest("view", "view", 2); // Remove the identifier that makes it a directory link so just plain history delete sites[2].link.directoryId; delete expectedPost.tiles[0].id; yield sendPingAndTest("view", "view", 2); // Add directory link at position 0 sites[0] = { isPinned: _ => false, link: { directoryId: 1234, frecency: 1000, url: "http://directory/", } }; expectedPost.tiles.unshift(JSON.parse(JSON.stringify({ id: 1234, pin: undefined, pos: undefined, score: undefined, url: undefined, }))); expectedPost.view = 1; yield sendPingAndTest("view", "view", 2); // Make the history tile enhanced so it reports both id and url sites[2].enhancedId = "id from enhanced"; expectedPost.tiles[1].id = "id from enhanced"; expectedPost.tiles[1].url = ""; yield sendPingAndTest("view", "view", 2); // Click the 0th site / 0th tile delete expectedPost.view; expectedPost.click = 0; yield sendPingAndTest("click", "click", 0); // Click the 2th site / 1th tile expectedPost.click = 1; yield sendPingAndTest("click", "click", 2); done = true; }); add_task(function test_fetchAndCacheLinks_local() { yield DirectoryLinksProvider.init(); yield cleanJsonFile(); // Trigger cache of data or chrome uri files in profD yield DirectoryLinksProvider._fetchAndCacheLinks(kTestURL); let data = yield readJsonFile(); isIdentical(data, kURLData); }); add_task(function test_fetchAndCacheLinks_remote() { yield DirectoryLinksProvider.init(); yield cleanJsonFile(); // this must trigger directory links json download and save it to cache file yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kExampleURL + "%LOCALE%"); do_check_eq(gLastRequestPath, kExamplePath + "en-US"); let data = yield readJsonFile(); isIdentical(data, kHttpHandlerData[kExamplePath]); }); add_task(function test_fetchAndCacheLinks_malformedURI() { yield DirectoryLinksProvider.init(); yield cleanJsonFile(); let someJunk = "some junk"; try { yield DirectoryLinksProvider._fetchAndCacheLinks(someJunk); do_throw("Malformed URIs should fail") } catch (e) { do_check_eq(e, "Error fetching " + someJunk) } // File should be empty. let data = yield readJsonFile(); isIdentical(data, ""); }); add_task(function test_fetchAndCacheLinks_unknownHost() { yield DirectoryLinksProvider.init(); yield cleanJsonFile(); let nonExistentServer = "http://localhost:56789/"; try { yield DirectoryLinksProvider._fetchAndCacheLinks(nonExistentServer); do_throw("BAD URIs should fail"); } catch (e) { do_check_true(e.startsWith("Fetching " + nonExistentServer + " results in error code: ")) } // File should be empty. let data = yield readJsonFile(); isIdentical(data, ""); }); add_task(function test_fetchAndCacheLinks_non200Status() { yield DirectoryLinksProvider.init(); yield cleanJsonFile(); yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kFailURL); do_check_eq(gLastRequestPath, kFailPath); let data = yield readJsonFile(); isIdentical(data, {}); }); // To test onManyLinksChanged observer, trigger a fetch add_task(function test_DirectoryLinksProvider__linkObservers() { yield DirectoryLinksProvider.init(); let testObserver = new LinksChangeObserver(); DirectoryLinksProvider.addObserver(testObserver); do_check_eq(DirectoryLinksProvider._observers.size, 1); DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true); yield testObserver.deferred.promise; DirectoryLinksProvider._removeObservers(); do_check_eq(DirectoryLinksProvider._observers.size, 0); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_linksURL_locale() { let data = { "en-US": [{url: "http://example.com", title: "US"}], "zh-CN": [ {url: "http://example.net", title: "CN"}, {url:"http://example.net/2", title: "CN2"} ], }; let dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); let links; let expected_data; links = yield fetchData(); do_check_eq(links.length, 1); expected_data = [{url: "http://example.com", title: "US", frecency: DIRECTORY_FRECENCY, lastVisitDate: 1}]; isIdentical(links, expected_data); yield promiseDirectoryDownloadOnPrefChange("general.useragent.locale", "zh-CN"); links = yield fetchData(); do_check_eq(links.length, 2) expected_data = [ {url: "http://example.net", title: "CN", frecency: DIRECTORY_FRECENCY, lastVisitDate: 2}, {url: "http://example.net/2", title: "CN2", frecency: DIRECTORY_FRECENCY, lastVisitDate: 1} ]; isIdentical(links, expected_data); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider__prefObserver_url() { yield promiseSetupDirectoryLinksProvider({linksURL: kTestURL}); let links = yield fetchData(); do_check_eq(links.length, 1); let expectedData = [{url: "http://example.com", title: "LocalSource", frecency: DIRECTORY_FRECENCY, lastVisitDate: 1}]; isIdentical(links, expectedData); // tests these 2 things: // 1. _linksURL is properly set after the pref change // 2. invalid source url is correctly handled let exampleUrl = 'http://localhost:56789/bad'; yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, exampleUrl); do_check_eq(DirectoryLinksProvider._linksURL, exampleUrl); // since the download fail, the directory file must remain the same let newLinks = yield fetchData(); isIdentical(newLinks, expectedData); // now remove the file, and re-download yield cleanJsonFile(); yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, exampleUrl + " "); // we now should see empty links newLinks = yield fetchData(); isIdentical(newLinks, []); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_getLinks_noLocaleData() { yield promiseSetupDirectoryLinksProvider({locale: 'zh-CN'}); let links = yield fetchData(); do_check_eq(links.length, 0); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_getLinks_badData() { let data = { "en-US": { "en-US": [{url: "http://example.com", title: "US"}], }, }; let dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); // Make sure we get nothing for incorrectly formatted data let links = yield fetchData(); do_check_eq(links.length, 0); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_needsDownload() { // test timestamping DirectoryLinksProvider._lastDownloadMS = 0; do_check_true(DirectoryLinksProvider._needsDownload); DirectoryLinksProvider._lastDownloadMS = Date.now(); do_check_false(DirectoryLinksProvider._needsDownload); DirectoryLinksProvider._lastDownloadMS = Date.now() - (60*60*24 + 1)*1000; do_check_true(DirectoryLinksProvider._needsDownload); DirectoryLinksProvider._lastDownloadMS = 0; }); add_task(function test_DirectoryLinksProvider_fetchAndCacheLinksIfNecessary() { yield DirectoryLinksProvider.init(); yield cleanJsonFile(); // explicitly change source url to cause the download during setup yield promiseSetupDirectoryLinksProvider({linksURL: kTestURL+" "}); yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(); // inspect lastDownloadMS timestamp which should be 5 seconds less then now() let lastDownloadMS = DirectoryLinksProvider._lastDownloadMS; do_check_true((Date.now() - lastDownloadMS) < 5000); // we should have fetched a new file during setup let data = yield readJsonFile(); isIdentical(data, kURLData); // attempt to download again - the timestamp should not change yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(); do_check_eq(DirectoryLinksProvider._lastDownloadMS, lastDownloadMS); // clean the file and force the download yield cleanJsonFile(); yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true); data = yield readJsonFile(); isIdentical(data, kURLData); // make sure that failed download does not corrupt the file, nor changes lastDownloadMS lastDownloadMS = DirectoryLinksProvider._lastDownloadMS; yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, "http://"); yield DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true); data = yield readJsonFile(); isIdentical(data, kURLData); do_check_eq(DirectoryLinksProvider._lastDownloadMS, lastDownloadMS); // _fetchAndCacheLinksIfNecessary must return same promise if download is in progress let downloadPromise = DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true); let anotherPromise = DirectoryLinksProvider._fetchAndCacheLinksIfNecessary(true); do_check_true(downloadPromise === anotherPromise); yield downloadPromise; yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_fetchDirectoryOnPrefChange() { yield DirectoryLinksProvider.init(); let testObserver = new LinksChangeObserver(); DirectoryLinksProvider.addObserver(testObserver); yield cleanJsonFile(); // ensure that provider does not think it needs to download do_check_false(DirectoryLinksProvider._needsDownload); // change the source URL, which should force directory download yield promiseDirectoryDownloadOnPrefChange(kSourceUrlPref, kExampleURL); // then wait for testObserver to fire and test that json is downloaded yield testObserver.deferred.promise; do_check_eq(gLastRequestPath, kExamplePath); let data = yield readJsonFile(); isIdentical(data, kHttpHandlerData[kExamplePath]); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_fetchDirectoryOnShow() { yield promiseSetupDirectoryLinksProvider(); // set lastdownload to 0 to make DirectoryLinksProvider want to download DirectoryLinksProvider._lastDownloadMS = 0; do_check_true(DirectoryLinksProvider._needsDownload); // download should happen on view yield DirectoryLinksProvider.reportSitesAction([], "view"); do_check_true(DirectoryLinksProvider._lastDownloadMS != 0); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_fetchDirectoryOnInit() { // ensure preferences are set to defaults yield promiseSetupDirectoryLinksProvider(); // now clean to provider, so we can init it again yield promiseCleanDirectoryLinksProvider(); yield cleanJsonFile(); yield DirectoryLinksProvider.init(); let data = yield readJsonFile(); isIdentical(data, kURLData); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_getLinksFromCorruptedFile() { yield promiseSetupDirectoryLinksProvider(); // write bogus json to a file and attempt to fetch from it let directoryLinksFilePath = OS.Path.join(OS.Constants.Path.profileDir, DIRECTORY_LINKS_FILE); yield OS.File.writeAtomic(directoryLinksFilePath, '{"en-US":'); let data = yield fetchData(); isIdentical(data, []); yield promiseCleanDirectoryLinksProvider(); }); add_task(function test_DirectoryLinksProvider_getAllowedLinks() { let data = {"en-US": [ {url: "ftp://example.com"}, {url: "http://example.net"}, {url: "javascript:5"}, {url: "https://example.com"}, {url: "httpJUNKjavascript:42"}, {url: "data:text/plain,hi"}, {url: "http/bork:eh"}, ]}; let dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); let links = yield fetchData(); do_check_eq(links.length, 2); // The only remaining url should be http and https do_check_eq(links[0].url, data["en-US"][1].url); do_check_eq(links[1].url, data["en-US"][3].url); }); add_task(function test_DirectoryLinksProvider_getAllowedImages() { let data = {"en-US": [ {url: "http://example.com", imageURI: "ftp://example.com"}, {url: "http://example.com", imageURI: "http://example.net"}, {url: "http://example.com", imageURI: "javascript:5"}, {url: "http://example.com", imageURI: "https://example.com"}, {url: "http://example.com", imageURI: "httpJUNKjavascript:42"}, {url: "http://example.com", imageURI: "data:text/plain,hi"}, {url: "http://example.com", imageURI: "http/bork:eh"}, ]}; let dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); let links = yield fetchData(); do_check_eq(links.length, 2); // The only remaining images should be https and data do_check_eq(links[0].imageURI, data["en-US"][3].imageURI); do_check_eq(links[1].imageURI, data["en-US"][5].imageURI); }); add_task(function test_DirectoryLinksProvider_getAllowedEnhancedImages() { let data = {"en-US": [ {url: "http://example.com", enhancedImageURI: "ftp://example.com"}, {url: "http://example.com", enhancedImageURI: "http://example.net"}, {url: "http://example.com", enhancedImageURI: "javascript:5"}, {url: "http://example.com", enhancedImageURI: "https://example.com"}, {url: "http://example.com", enhancedImageURI: "httpJUNKjavascript:42"}, {url: "http://example.com", enhancedImageURI: "data:text/plain,hi"}, {url: "http://example.com", enhancedImageURI: "http/bork:eh"}, ]}; let dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); let links = yield fetchData(); do_check_eq(links.length, 2); // The only remaining enhancedImages should be http and https and data do_check_eq(links[0].enhancedImageURI, data["en-US"][3].enhancedImageURI); do_check_eq(links[1].enhancedImageURI, data["en-US"][5].enhancedImageURI); }); add_task(function test_DirectoryLinksProvider_getEnhancedLink() { let data = {"en-US": [ {url: "http://example.net", enhancedImageURI: "data:,net1"}, {url: "http://example.com", enhancedImageURI: "data:,com1"}, {url: "http://example.com", enhancedImageURI: "data:,com2"}, ]}; let dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); let links = yield fetchData(); do_check_eq(links.length, 3); function checkEnhanced(url, image) { let enhanced = DirectoryLinksProvider.getEnhancedLink({url: url}); do_check_eq(enhanced && enhanced.enhancedImageURI, image); } // Get the expected image for the same site checkEnhanced("http://example.net/", "data:,net1"); checkEnhanced("http://example.net/path", "data:,net1"); checkEnhanced("https://www.example.net/", "data:,net1"); checkEnhanced("https://www3.example.net/", "data:,net1"); // Get the image of the last entry checkEnhanced("http://example.com", "data:,com2"); // Get the inline enhanced image let inline = DirectoryLinksProvider.getEnhancedLink({ url: "http://example.com/echo", enhancedImageURI: "data:,echo", }); do_check_eq(inline.enhancedImageURI, "data:,echo"); do_check_eq(inline.url, "http://example.com/echo"); // Undefined for not enhanced checkEnhanced("http://sub.example.net/", undefined); checkEnhanced("http://example.org", undefined); checkEnhanced("http://localhost", undefined); checkEnhanced("http://127.0.0.1", undefined); // Make sure old data is not cached data = {"en-US": [ {url: "http://example.com", enhancedImageURI: "data:,fresh"}, ]}; dataURI = 'data:application/json,' + JSON.stringify(data); yield promiseSetupDirectoryLinksProvider({linksURL: dataURI}); links = yield fetchData(); do_check_eq(links.length, 1); checkEnhanced("http://example.net", undefined); checkEnhanced("http://example.com", "data:,fresh"); }); add_task(function test_DirectoryLinksProvider_setDefaultEnhanced() { function checkDefault(expected) { Services.prefs.clearUserPref(kNewtabEnhancedPref); do_check_eq(Services.prefs.getBoolPref(kNewtabEnhancedPref), expected); } // Use the default donottrack prefs (enabled = false) Services.prefs.clearUserPref("privacy.donottrackheader.enabled"); checkDefault(true); // Turn on DNT - no track Services.prefs.setBoolPref("privacy.donottrackheader.enabled", true); checkDefault(false); // Turn off DNT header Services.prefs.clearUserPref("privacy.donottrackheader.enabled"); checkDefault(true); // Clean up Services.prefs.clearUserPref("privacy.donottrackheader.value"); });