/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; /** * TO TEST: * - test state saved on doorhanger dismissal * - links to switch steps * - TOS and PP link clicks * - identityList is populated correctly */ Services.prefs.setBoolPref("toolkit.identity.debug", true); XPCOMUtils.defineLazyModuleGetter(this, "IdentityService", "resource://gre/modules/identity/Identity.jsm"); const TEST_ORIGIN = "https://example.com"; const TEST_EMAIL = "user@example.com"; let gTestIndex = 0; let outerWinId = gBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIDOMWindowUtils).outerWindowID; function NotificationBase(aNotId) { this.id = aNotId; } NotificationBase.prototype = { message: TEST_ORIGIN, mainAction: { label: "", callback: function() { this.mainActionClicked = true; }.bind(this), }, secondaryActions: [], options: { "identity": { origin: TEST_ORIGIN, rpId: outerWinId, }, }, }; let tests = [ { name: "test_request_required_typed", run: function() { setupRPFlow(); this.notifyOptions = { rpId: outerWinId, origin: TEST_ORIGIN, }; this.notifyObj = new NotificationBase("identity-request"); Services.obs.notifyObservers({wrappedJSObject: this.notifyOptions}, "identity-request", null); }, onShown: function(popup) { checkPopup(popup, this.notifyObj); let notification = popup.childNodes[0]; // Check identity popup state let state = notification.identity; ok(!state.typedEmail, "Nothing should be typed yet"); ok(!state.selected, "Identity should not be selected yet"); ok(!state.termsOfService, "No TOS specified"); ok(!state.privacyPolicy, "No PP specified"); is(state.step, 0, "Step should be persisted with default value"); is(state.rpId, outerWinId, "Check rpId"); is(state.origin, TEST_ORIGIN, "Check origin"); is(notification.step, 0, "Should be on the new email step"); is(notification.chooseEmailLink.hidden, true, "Identity list is empty so link to list view should be hidden"); is(notification.addEmailLink.parentElement.hidden, true, "We are already on the email input step so choose email pane should be hidden"); is(notification.emailField.value, "", "Email field should default to empty on a new notification"); let notifDoc = notification.ownerDocument; ok(notifDoc.getAnonymousElementByAttribute(notification, "anonid", "tos").hidden, "TOS link should be hidden"); ok(notifDoc.getAnonymousElementByAttribute(notification, "anonid", "privacypolicy").hidden, "PP link should be hidden"); // Try to continue with a missing email address triggerMainCommand(popup); is(notification.throbber.style.visibility, "hidden", "is throbber visible"); ok(!notification.button.disabled, "Button should not be disabled"); is(window.gIdentitySelected, null, "Check no identity selected"); // Fill in an invalid email address and try again notification.emailField.value = "foo"; triggerMainCommand(popup); is(notification.throbber.style.visibility, "hidden", "is throbber visible"); ok(!notification.button.disabled, "Button should not be disabled"); is(window.gIdentitySelected, null, "Check no identity selected"); // Fill in an email address and try again notification.emailField.value = TEST_EMAIL; triggerMainCommand(popup); is(window.gIdentitySelected.rpId, outerWinId, "Check identity selected rpId"); is(window.gIdentitySelected.identity, TEST_EMAIL, "Check identity selected email"); is(notification.identity.selected, TEST_EMAIL, "Check persisted email"); is(notification.throbber.style.visibility, "visible", "is throbber visible"); ok(notification.button.disabled, "Button should be disabled"); ok(notification.emailField.disabled, "Email field should be disabled"); ok(notification.identityList.disabled, "Identity list should be disabled"); PopupNotifications.getNotification("identity-request").remove(); }, onHidden: function(popup) { }, }, { name: "test_request_optional", run: function() { this.notifyOptions = { rpId: outerWinId, origin: TEST_ORIGIN, privacyPolicy: TEST_ORIGIN + "/pp.txt", termsOfService: TEST_ORIGIN + "/tos.tzt", }; this.notifyObj = new NotificationBase("identity-request"); Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions }, "identity-request", null); }, onShown: function(popup) { checkPopup(popup, this.notifyObj); let notification = popup.childNodes[0]; // Check identity popup state let state = notification.identity; ok(!state.typedEmail, "Nothing should be typed yet"); ok(!state.selected, "Identity should not be selected yet"); is(state.termsOfService, this.notifyOptions.termsOfService, "Check TOS URL"); is(state.privacyPolicy, this.notifyOptions.privacyPolicy, "Check PP URL"); is(state.step, 0, "Step should be persisted with default value"); is(state.rpId, outerWinId, "Check rpId"); is(state.origin, TEST_ORIGIN, "Check origin"); is(notification.step, 0, "Should be on the new email step"); is(notification.chooseEmailLink.hidden, true, "Identity list is empty so link to list view should be hidden"); is(notification.addEmailLink.parentElement.hidden, true, "We are already on the email input step so choose email pane should be hidden"); is(notification.emailField.value, "", "Email field should default to empty on a new notification"); let notifDoc = notification.ownerDocument; let tosLink = notifDoc.getAnonymousElementByAttribute(notification, "anonid", "tos"); ok(!tosLink.hidden, "TOS link should be visible"); is(tosLink.href, this.notifyOptions.termsOfService, "Check TOS link URL"); let ppLink = notifDoc.getAnonymousElementByAttribute(notification, "anonid", "privacypolicy"); ok(!ppLink.hidden, "PP link should be visible"); is(ppLink.href, this.notifyOptions.privacyPolicy, "Check PP link URL"); // Try to continue with a missing email address triggerMainCommand(popup); is(notification.throbber.style.visibility, "hidden", "is throbber visible"); ok(!notification.button.disabled, "Button should not be disabled"); is(window.gIdentitySelected, null, "Check no identity selected"); // Fill in an invalid email address and try again notification.emailField.value = "foo"; triggerMainCommand(popup); is(notification.throbber.style.visibility, "hidden", "is throbber visible"); ok(!notification.button.disabled, "Button should not be disabled"); is(window.gIdentitySelected, null, "Check no identity selected"); // Fill in an email address and try again notification.emailField.value = TEST_EMAIL; triggerMainCommand(popup); is(window.gIdentitySelected.rpId, outerWinId, "Check identity selected rpId"); is(window.gIdentitySelected.identity, TEST_EMAIL, "Check identity selected email"); is(notification.identity.selected, TEST_EMAIL, "Check persisted email"); is(notification.throbber.style.visibility, "visible", "is throbber visible"); ok(notification.button.disabled, "Button should be disabled"); ok(notification.emailField.disabled, "Email field should be disabled"); ok(notification.identityList.disabled, "Identity list should be disabled"); PopupNotifications.getNotification("identity-request").remove(); }, onHidden: function(popup) {}, }, { name: "test_login_state_changed", run: function () { this.notifyOptions = { rpId: outerWinId, }; this.notifyObj = new NotificationBase("identity-logged-in"); this.notifyObj.message = "Signed in as: user@example.com"; this.notifyObj.mainAction.label = "Sign Out"; this.notifyObj.mainAction.accessKey = "O"; Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions }, "identity-login-state-changed", TEST_EMAIL); executeSoon(function() { PopupNotifications.getNotification("identity-logged-in").anchorElement.click(); }); }, onShown: function(popup) { checkPopup(popup, this.notifyObj); // Fire the notification that the user is no longer logged-in to close the UI. Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions }, "identity-login-state-changed", null); }, onHidden: function(popup) {}, }, { name: "test_login_state_changed_logout", run: function () { this.notifyOptions = { rpId: outerWinId, }; this.notifyObj = new NotificationBase("identity-logged-in"); this.notifyObj.message = "Signed in as: user@example.com"; this.notifyObj.mainAction.label = "Sign Out"; this.notifyObj.mainAction.accessKey = "O"; Services.obs.notifyObservers({ wrappedJSObject: this.notifyOptions }, "identity-login-state-changed", TEST_EMAIL); executeSoon(function() { PopupNotifications.getNotification("identity-logged-in").anchorElement.click(); }); }, onShown: function(popup) { checkPopup(popup, this.notifyObj); // This time trigger the Sign Out button and make sure the UI goes away. triggerMainCommand(popup); }, onHidden: function(popup) {}, }, ]; function test_auth() { let notifyOptions = { provId: outerWinId, origin: TEST_ORIGIN, }; Services.obs.addObserver(function() { // prepare to send auth-complete and close the window let winCloseObs = new WindowObserver(function(closedWin) { info("closed window"); finish(); }, "domwindowclosed"); Services.ww.registerNotification(winCloseObs); Services.obs.notifyObservers(null, "identity-auth-complete", IdentityService.IDP.authenticationFlowSet.authId); }, "test-identity-auth-window", false); let winObs = new WindowObserver(function(authWin) { ok(authWin, "Authentication window opened"); ok(authWin.contentWindow.location); }); Services.ww.registerNotification(winObs); Services.obs.notifyObservers({ wrappedJSObject: notifyOptions }, "identity-auth", TEST_ORIGIN + "/auth"); } function test() { waitForExplicitFinish(); registerCleanupFunction(cleanUp); let sitw = {}; Components.utils.import("resource:///modules/SignInToWebsite.jsm", sitw); ok(sitw.SignInToWebsiteUX, "SignInToWebsiteUX object exists"); // Replace implementation of ID Service functions for testing window.selectIdentity = sitw.SignInToWebsiteUX.selectIdentity; sitw.SignInToWebsiteUX.selectIdentity = function(aRpId, aIdentity) { info("Identity selected: " + aIdentity); window.gIdentitySelected = {rpId: aRpId, identity: aIdentity}; }; window.setAuthenticationFlow = IdentityService.IDP.setAuthenticationFlow; IdentityService.IDP.setAuthenticationFlow = function(aAuthId, aProvId) { info("setAuthenticationFlow: " + aAuthId + " : " + aProvId); this.authenticationFlowSet = { authId: aAuthId, provId: aProvId }; Services.obs.notifyObservers(null, "test-identity-auth-window", aAuthId); }; runNextTest(); } // Cleanup between tests function resetState() { delete window.gIdentitySelected; delete IdentityService.IDP.authenticationFlowSet; IdentityService.reset(); } // Cleanup after all tests function cleanUp() { info("cleanup"); resetState(); for (let topic in gActiveObservers) Services.obs.removeObserver(gActiveObservers[topic], topic); for (let eventName in gActiveListeners) PopupNotifications.panel.removeEventListener(eventName, gActiveListeners[eventName], false); delete IdentityService.RP._rpFlows[outerWinId]; // Put the JSM functions back to how they were IdentityService.IDP.setAuthenticationFlow = window.setAuthenticationFlow; delete window.setAuthenticationFlow; let sitw = {}; Components.utils.import("resource:///modules/SignInToWebsite.jsm", sitw); sitw.SignInToWebsiteUX.selectIdentity = window.selectIdentity; delete window.selectIdentity; Services.prefs.clearUserPref("toolkit.identity.debug"); } let gActiveListeners = {}; let gActiveObservers = {}; let gShownState = {}; function runNextTest() { let nextTest = tests[gTestIndex]; function goNext() { resetState(); if (++gTestIndex == tests.length) executeSoon(test_auth); else executeSoon(runNextTest); } function addObserver(topic) { function observer() { Services.obs.removeObserver(observer, "PopupNotifications-" + topic); delete gActiveObservers["PopupNotifications-" + topic]; info("[Test #" + gTestIndex + "] observer for " + topic + " called"); nextTest[topic](); goNext(); } Services.obs.addObserver(observer, "PopupNotifications-" + topic, false); gActiveObservers["PopupNotifications-" + topic] = observer; } if (nextTest.backgroundShow) { addObserver("backgroundShow"); } else if (nextTest.updateNotShowing) { addObserver("updateNotShowing"); } else { doOnPopupEvent("popupshowing", function () { info("[Test #" + gTestIndex + "] popup showing"); }); doOnPopupEvent("popupshown", function () { gShownState[gTestIndex] = true; info("[Test #" + gTestIndex + "] popup shown"); nextTest.onShown(this); }); // We allow multiple onHidden functions to be defined in an array. They're // called in the order they appear. let onHiddenArray = nextTest.onHidden instanceof Array ? nextTest.onHidden : [nextTest.onHidden]; doOnPopupEvent("popuphidden", function () { if (!gShownState[gTestIndex]) { // TODO: needed? info("Popup from test " + gTestIndex + " was hidden before its popupshown fired"); } let onHidden = onHiddenArray.shift(); info("[Test #" + gTestIndex + "] popup hidden (" + onHiddenArray.length + " hides remaining)"); executeSoon(function () { onHidden.call(nextTest, this); if (!onHiddenArray.length) goNext(); }.bind(this)); }, onHiddenArray.length); info("[Test #" + gTestIndex + "] added listeners; panel state: " + PopupNotifications.isPanelOpen); } info("[Test #" + gTestIndex + "] running test"); nextTest.run(); } function doOnPopupEvent(eventName, callback, numExpected) { gActiveListeners[eventName] = function (event) { if (event.target != PopupNotifications.panel) return; if (typeof(numExpected) === "number") numExpected--; if (!numExpected) { PopupNotifications.panel.removeEventListener(eventName, gActiveListeners[eventName], false); delete gActiveListeners[eventName]; } callback.call(PopupNotifications.panel); }; PopupNotifications.panel.addEventListener(eventName, gActiveListeners[eventName], false); } function checkPopup(popup, notificationObj) { info("[Test #" + gTestIndex + "] checking popup"); let notifications = popup.childNodes; is(notifications.length, 1, "only one notification displayed"); let notification = notifications[0]; let icon = document.getAnonymousElementByAttribute(notification, "class", "popup-notification-icon"); is(notification.getAttribute("label"), notificationObj.message, "message matches"); is(notification.id, notificationObj.id + "-notification", "id matches"); if (notificationObj.id != "identity-request" && notificationObj.mainAction) { is(notification.getAttribute("buttonlabel"), notificationObj.mainAction.label, "main action label matches"); is(notification.getAttribute("buttonaccesskey"), notificationObj.mainAction.accessKey, "main action accesskey matches"); } let actualSecondaryActions = notification.childNodes; let secondaryActions = notificationObj.secondaryActions || []; let actualSecondaryActionsCount = actualSecondaryActions.length; if (secondaryActions.length) { let lastChild = actualSecondaryActions.item(actualSecondaryActions.length - 1); is(lastChild.tagName, "menuseparator", "menuseparator exists"); actualSecondaryActionsCount--; } is(actualSecondaryActionsCount, secondaryActions.length, actualSecondaryActions.length + " secondary actions"); secondaryActions.forEach(function (a, i) { is(actualSecondaryActions[i].getAttribute("label"), a.label, "label for secondary action " + i + " matches"); is(actualSecondaryActions[i].getAttribute("accesskey"), a.accessKey, "accessKey for secondary action " + i + " matches"); }); } function triggerMainCommand(popup) { info("[Test #" + gTestIndex + "] triggering main command"); let notifications = popup.childNodes; ok(notifications.length > 0, "at least one notification displayed"); let notification = notifications[0]; // 20, 10 so that the inner button is hit EventUtils.synthesizeMouse(notification.button, 20, 10, {}); } function triggerSecondaryCommand(popup, index) { info("[Test #" + gTestIndex + "] triggering secondary command"); let notifications = popup.childNodes; ok(notifications.length > 0, "at least one notification displayed"); let notification = notifications[0]; notification.button.focus(); popup.addEventListener("popupshown", function () { popup.removeEventListener("popupshown", arguments.callee, false); // Press down until the desired command is selected for (let i = 0; i <= index; i++) EventUtils.synthesizeKey("VK_DOWN", {}); // Activate EventUtils.synthesizeKey("VK_ENTER", {}); }, false); // One down event to open the popup EventUtils.synthesizeKey("VK_DOWN", { altKey: (navigator.platform.indexOf("Mac") == -1) }); } function dismissNotification(popup) { info("[Test #" + gTestIndex + "] dismissing notification"); executeSoon(function () { EventUtils.synthesizeKey("VK_ESCAPE", {}); }); } function partial(fn) { let args = Array.prototype.slice.call(arguments, 1); return function() { return fn.apply(this, args.concat(Array.prototype.slice.call(arguments))); }; } // create a mock "doc" object, which the Identity Service // uses as a pointer back into the doc object function mock_doc(aIdentity, aOrigin, aDoFunc) { let mockedDoc = {}; mockedDoc.id = outerWinId; mockedDoc.loggedInEmail = aIdentity; mockedDoc.origin = aOrigin; mockedDoc['do'] = aDoFunc; mockedDoc.doReady = partial(aDoFunc, 'ready'); mockedDoc.doLogin = partial(aDoFunc, 'login'); mockedDoc.doLogout = partial(aDoFunc, 'logout'); mockedDoc.doError = partial(aDoFunc, 'error'); mockedDoc.doCancel = partial(aDoFunc, 'cancel'); mockedDoc.doCoffee = partial(aDoFunc, 'coffee'); return mockedDoc; } // takes a list of functions and returns a function that // when called the first time, calls the first func, // then the next time the second, etc. function call_sequentially() { let numCalls = 0; let funcs = arguments; return function() { if (!funcs[numCalls]) { let argString = Array.prototype.slice.call(arguments).join(","); ok(false, "Too many calls: " + argString); return; } funcs[numCalls].apply(funcs[numCalls], arguments); numCalls += 1; }; } function setupRPFlow(aIdentity) { IdentityService.RP.watch(mock_doc(aIdentity, TEST_ORIGIN, call_sequentially( function(action, params) { is(action, "ready", "1st callback"); is(params, null); }, function(action, params) { is(action, "logout", "2nd callback"); is(params, null); }, function(action, params) { is(action, "ready", "3rd callback"); is(params, null); } ))); } function WindowObserver(aCallback, aObserveTopic = "domwindowopened") { this.observe = function(aSubject, aTopic, aData) { if (aTopic != aObserveTopic) { return; } info(aObserveTopic); Services.ww.unregisterNotification(this); SimpleTest.executeSoon(function() { let domWin = aSubject.QueryInterface(Ci.nsIDOMWindow); aCallback(domWin); }); }; }