Bug 1047164 - Handle authentication errors (e.g. token expiry) for FxA Loop sessions and notify users. r=jaws

This commit is contained in:
Matthew Noorenberghe 2014-10-03 03:17:32 -07:00
parent 68a2628da0
commit 5c7ac1f4de
18 changed files with 546 additions and 78 deletions

View File

@ -144,6 +144,28 @@ function injectLoopAPI(targetWindow) {
}
},
errors: {
enumerable: true,
get: function() {
let errors = {};
for (let [type, error] of MozLoopService.errors) {
// if error.error is an nsIException, just delete it since it's hard
// to clone across the boundary.
if (error.error instanceof Ci.nsIException) {
MozLoopService.log.debug("Warning: Some errors were omitted from MozLoopAPI.errors " +
"due to issues copying nsIException across boundaries.",
error.error);
delete error.error;
}
// We have to clone the error property since it may be an Error object.
errors[type] = Cu.cloneInto(error, targetWindow);
}
return Cu.cloneInto(errors, targetWindow);
},
},
/**
* Returns the current locale of the browser.
*
@ -513,9 +535,9 @@ function injectLoopAPI(targetWindow) {
}, targetWindow);
} catch (ex) {
// only log outside of xpcshell to avoid extra message noise
if (typeof window !== 'undefined' && "console" in window) {
console.log("Failed to construct appVersionInfo; if this isn't " +
"an xpcshell unit test, something is wrong", ex);
if (typeof targetWindow !== 'undefined' && "console" in targetWindow) {
MozLoopService.log.error("Failed to construct appVersionInfo; if this isn't " +
"an xpcshell unit test, something is wrong", ex);
}
}
}

View File

@ -4,7 +4,7 @@
"use strict";
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
// Invalid auth token as per
// https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
@ -330,11 +330,60 @@ let MozLoopServiceInternal = {
},
/**
* Record an error and notify interested UI with the relevant user-facing strings attached.
*
* @param {String} errorType a key to identify the type of error. Only one
* error of a type will be saved at a time.
* error of a type will be saved at a time. This value may be used to
* determine user-facing (aka. friendly) strings.
* @param {Object} error an object describing the error in the format from Hawk errors
*/
setError: function(errorType, error) {
let messageString, detailsString, detailsButtonLabelString;
const NETWORK_ERRORS = [
Cr.NS_ERROR_CONNECTION_REFUSED,
Cr.NS_ERROR_NET_INTERRUPT,
Cr.NS_ERROR_NET_RESET,
Cr.NS_ERROR_NET_TIMEOUT,
Cr.NS_ERROR_OFFLINE,
Cr.NS_ERROR_PROXY_CONNECTION_REFUSED,
Cr.NS_ERROR_UNKNOWN_HOST,
Cr.NS_ERROR_UNKNOWN_PROXY_HOST,
];
if (error.code === null && error.errno === null &&
error.error instanceof Ci.nsIException &&
NETWORK_ERRORS.indexOf(error.error.result) != -1) {
// Network error. Override errorType so we can easily clear it on the next succesful request.
errorType = "network";
messageString = "could_not_connect";
detailsString = "check_internet_connection";
detailsButtonLabelString = "retry_button";
} else if (errorType == "profile" && error.code >= 500 && error.code < 600) {
messageString = "problem_accessing_account";
} else if (error.code == 401) {
if (errorType == "login") {
messageString = "could_not_authenticate"; // XXX: Bug 1076377
detailsString = "password_changed_question";
detailsButtonLabelString = "retry_button";
} else {
messageString = "session_expired_error_description";
}
} else if (error.code >= 500 && error.code < 600) {
messageString = "service_not_available";
detailsString = "try_again_later";
detailsButtonLabelString = "retry_button";
} else {
messageString = "generic_failure_title";
}
error.friendlyMessage = this.localizedStrings[messageString].textContent;
error.friendlyDetails = detailsString ?
this.localizedStrings[detailsString].textContent :
null;
error.friendlyDetailsButtonLabel = detailsButtonLabelString ?
this.localizedStrings[detailsButtonLabelString].textContent :
null;
gErrors.set(errorType, error);
this.notifyStatusChanged();
},
@ -411,7 +460,30 @@ let MozLoopServiceInternal = {
2 * 32, true);
}
return gHawkClient.request(path, method, credentials, payloadObj);
return gHawkClient.request(path, method, credentials, payloadObj).then((result) => {
this.clearError("network");
return result;
}, (error) => {
if (error.code == 401) {
this.clearSessionToken(sessionType);
if (sessionType == LOOP_SESSION_TYPE.FXA) {
MozLoopService.logOutFromFxA().then(() => {
// Set a user-visible error after logOutFromFxA clears existing ones.
this.setError("login", error);
});
} else {
if (!this.urlExpiryTimeIsInFuture()) {
// If there are no Guest URLs in the future, don't use setError to notify the user since
// there isn't a need for a Guest registration at this time.
throw error;
}
this.setError("registration", error);
}
}
throw error;
});
},
/**
@ -534,21 +606,13 @@ let MozLoopServiceInternal = {
}, (error) => {
// There's other errors than invalid auth token, but we should only do the reset
// as a last resort.
if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
if (this.urlExpiryTimeIsInFuture()) {
// XXX Should this be reported to the user is a visible manner?
Cu.reportError("Loop session token is invalid, all previously "
+ "generated urls will no longer work.");
}
if (error.code === 401) {
// Authorization failed, invalid token, we need to try again with a new token.
this.clearSessionToken(sessionType);
if (retry) {
return this.registerWithLoopServer(sessionType, pushUrl, false);
}
}
// XXX Bubble the precise details up to the UI somehow (bug 1013248).
log.error("Failed to register with the loop server. Error: ", error);
this.setError("registration", error);
throw error;
@ -568,6 +632,11 @@ let MozLoopServiceInternal = {
* @return {Promise} resolving when the unregistration request finishes
*/
unregisterFromLoopServer: function(sessionType, pushURL) {
let prefType = Services.prefs.getPrefType(this.getSessionTokenPrefName(sessionType));
if (prefType == Services.prefs.PREF_INVALID) {
return Promise.resolve("already unregistered");
}
let unregisterURL = "/registration?simplePushURL=" + encodeURIComponent(pushURL);
return this.hawkRequest(sessionType, unregisterURL, "DELETE")
.then(() => {
@ -577,7 +646,7 @@ let MozLoopServiceInternal = {
error => {
// Always clear the registration token regardless of whether the server acknowledges the logout.
MozLoopServiceInternal.clearSessionToken(sessionType);
if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
if (error.code === 401) {
// Authorization failed, invalid token. This is fine since it may mean we already logged out.
return;
}
@ -1117,6 +1186,7 @@ this.MozLoopService = {
* rejected with an error code or string.
*/
register: function(mockPushHandler, mockWebSocket) {
log.debug("registering");
// Don't do anything if loop is not enabled.
if (!Services.prefs.getBoolPref("loop.enabled")) {
throw new Error("Loop is not enabled");
@ -1196,6 +1266,10 @@ this.MozLoopService = {
return MozLoopServiceInternal.errors;
},
get log() {
return log;
},
/**
* Returns the current locale
*
@ -1331,6 +1405,8 @@ this.MozLoopService = {
} else {
throw new Error("No pushUrl for FxA registration");
}
MozLoopServiceInternal.clearError("login");
MozLoopServiceInternal.clearError("profile");
return gFxAOAuthTokenData;
}));
}).then(tokenData => {
@ -1343,6 +1419,7 @@ this.MozLoopService = {
MozLoopServiceInternal.notifyStatusChanged("login");
}, error => {
log.error("Failed to retrieve profile", error);
this.setError("profile", error);
gFxAOAuthProfile = null;
MozLoopServiceInternal.notifyStatusChanged();
});
@ -1351,6 +1428,10 @@ this.MozLoopService = {
gFxAOAuthTokenData = null;
gFxAOAuthProfile = null;
throw error;
}).catch((error) => {
MozLoopServiceInternal.setError("login", error);
// Re-throw for testing
throw error;
});
},
@ -1363,8 +1444,12 @@ this.MozLoopService = {
*/
logOutFromFxA: Task.async(function*() {
log.debug("logOutFromFxA");
yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
gPushHandler.pushUrl);
if (gPushHandler && gPushHandler.pushUrl) {
yield MozLoopServiceInternal.unregisterFromLoopServer(LOOP_SESSION_TYPE.FXA,
gPushHandler.pushUrl);
} else {
MozLoopServiceInternal.clearSessionToken(LOOP_SESSION_TYPE.FXA);
}
gFxAOAuthTokenData = null;
gFxAOAuthProfile = null;
@ -1377,6 +1462,8 @@ this.MozLoopService = {
// clearError calls notifyStatusChanged so should be done last when the
// state is clean.
MozLoopServiceInternal.clearError("registration");
MozLoopServiceInternal.clearError("login");
MozLoopServiceInternal.clearError("profile");
}),
openFxASettings: function() {

View File

@ -78,7 +78,7 @@ loop.Client = (function($) {
_failureHandler: function(cb, error) {
var message = "HTTP " + error.code + " " + error.error + "; " + error.message;
console.error(message);
cb(new Error(message));
cb(error);
},
/**

View File

@ -312,10 +312,12 @@ loop.panel = (function(_, mozL10n) {
},
_onCallUrlReceived: function(err, callUrlData) {
this.props.notifications.reset();
if (err) {
this.props.notifications.errorL10n("unable_retrieve_url");
if (err.code != 401) {
// 401 errors are already handled in hawkRequest and show an error
// message about the session.
this.props.notifications.errorL10n("unable_retrieve_url");
}
this.setState(this.getInitialState());
} else {
try {
@ -445,8 +447,36 @@ loop.panel = (function(_, mozL10n) {
};
},
_onAuthStatusChange: function() {
_serviceErrorToShow: function() {
if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
return null;
}
// Just get the first error for now since more than one should be rare.
var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
return {
type: firstErrorKey,
error: navigator.mozLoop.errors[firstErrorKey],
};
},
updateServiceErrors: function() {
var serviceError = this._serviceErrorToShow();
if (serviceError) {
this.props.notifications.set({
id: "service-error",
level: "error",
message: serviceError.error.friendlyMessage,
details: serviceError.error.friendlyDetails,
detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
});
} else {
this.props.notifications.remove(this.props.notifications.get("service-error"));
}
},
_onStatusChanged: function() {
this.setState({userProfile: navigator.mozLoop.userProfile});
this.updateServiceErrors();
},
startForm: function(name, contact) {
@ -458,12 +488,16 @@ loop.panel = (function(_, mozL10n) {
this.refs.tabView.setState({ selectedTab: name });
},
componentWillMount: function() {
this.updateServiceErrors();
},
componentDidMount: function() {
window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
window.addEventListener("LoopStatusChanged", this._onStatusChanged);
},
componentWillUnmount: function() {
window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
},
render: function() {

View File

@ -312,10 +312,12 @@ loop.panel = (function(_, mozL10n) {
},
_onCallUrlReceived: function(err, callUrlData) {
this.props.notifications.reset();
if (err) {
this.props.notifications.errorL10n("unable_retrieve_url");
if (err.code != 401) {
// 401 errors are already handled in hawkRequest and show an error
// message about the session.
this.props.notifications.errorL10n("unable_retrieve_url");
}
this.setState(this.getInitialState());
} else {
try {
@ -445,8 +447,36 @@ loop.panel = (function(_, mozL10n) {
};
},
_onAuthStatusChange: function() {
_serviceErrorToShow: function() {
if (!navigator.mozLoop.errors || !Object.keys(navigator.mozLoop.errors).length) {
return null;
}
// Just get the first error for now since more than one should be rare.
var firstErrorKey = Object.keys(navigator.mozLoop.errors)[0];
return {
type: firstErrorKey,
error: navigator.mozLoop.errors[firstErrorKey],
};
},
updateServiceErrors: function() {
var serviceError = this._serviceErrorToShow();
if (serviceError) {
this.props.notifications.set({
id: "service-error",
level: "error",
message: serviceError.error.friendlyMessage,
details: serviceError.error.friendlyDetails,
detailsButtonLabel: serviceError.error.friendlyDetailsButtonLabel,
});
} else {
this.props.notifications.remove(this.props.notifications.get("service-error"));
}
},
_onStatusChanged: function() {
this.setState({userProfile: navigator.mozLoop.userProfile});
this.updateServiceErrors();
},
startForm: function(name, contact) {
@ -458,12 +488,16 @@ loop.panel = (function(_, mozL10n) {
this.refs.tabView.setState({ selectedTab: name });
},
componentWillMount: function() {
this.updateServiceErrors();
},
componentDidMount: function() {
window.addEventListener("LoopStatusChanged", this._onAuthStatusChange);
window.addEventListener("LoopStatusChanged", this._onStatusChanged);
},
componentWillUnmount: function() {
window.removeEventListener("LoopStatusChanged", this._onAuthStatusChange);
window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
},
render: function() {

View File

@ -98,6 +98,7 @@ p {
.btn-info {
background-color: #0096dd;
border: 1px solid #0095dd;
color: #fff;
}
.btn-info:hover {
@ -229,12 +230,20 @@ p {
border-top-right-radius: 0;
}
/* Alerts */
/* Alerts/Notifications */
.notificationContainer {
border-bottom: 2px solid #E9E9E9;
margin-bottom: 1em;
}
.messages > .notificationContainer > .alert {
text-align: center;
}
.notificationContainer > .detailsBar,
.alert {
background: #eee;
padding: .4em 1em;
margin-bottom: 1em;
border-bottom: 2px solid #E9E9E9;
}
.alert p.message {
@ -252,6 +261,11 @@ p {
border: 1px solid #fbeed5;
}
.notificationContainer > .details-error {
background: #fbebeb;
color: #d74345
}
.alert .close {
position: relative;
top: -.1rem;

View File

@ -332,6 +332,8 @@ loop.shared.models = (function(l10n) {
*/
var NotificationModel = Backbone.Model.extend({
defaults: {
details: "",
detailsButtonLabel: "",
level: "info",
message: ""
}

View File

@ -617,9 +617,19 @@ loop.shared.views = (function(_, OT, l10n) {
render: function() {
var notification = this.props.notification;
return (
React.DOM.div({key: this.props.key,
className: "alert alert-" + notification.get("level")},
React.DOM.span({className: "message"}, notification.get("message"))
React.DOM.div({className: "notificationContainer"},
React.DOM.div({key: this.props.key,
className: "alert alert-" + notification.get("level")},
React.DOM.span({className: "message"}, notification.get("message"))
),
React.DOM.div({className: "detailsBar details-" + notification.get("level"),
hidden: !notification.get("details")},
React.DOM.button({className: "detailsButton btn-info",
hidden: true || !notification.get("detailsButtonLabel")},
notification.get("detailsButtonLabel")
),
React.DOM.span({className: "details"}, notification.get("details"))
)
)
);
}

View File

@ -617,9 +617,19 @@ loop.shared.views = (function(_, OT, l10n) {
render: function() {
var notification = this.props.notification;
return (
<div key={this.props.key}
className={"alert alert-" + notification.get("level")}>
<span className="message">{notification.get("message")}</span>
<div className="notificationContainer">
<div key={this.props.key}
className={"alert alert-" + notification.get("level")}>
<span className="message">{notification.get("message")}</span>
</div>
<div className={"detailsBar details-" + notification.get("level")}
hidden={!notification.get("details")}>
<button className="detailsButton btn-info"
hidden={true || !notification.get("detailsButtonLabel")}>
{notification.get("detailsButtonLabel")}
</button>
<span className="details">{notification.get("details")}</span>
</div>
</div>
);
}

View File

@ -100,7 +100,7 @@ describe("loop.Client", function() {
sinon.assert.calledOnce(callback);
sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
return /400.*invalid token/.test(err.message);
return err.code == 400 && "invalid token" == err.message;
}));
});
});
@ -218,7 +218,7 @@ describe("loop.Client", function() {
sinon.assert.calledOnce(callback);
sinon.assert.calledWithMatch(callback, sinon.match(function(err) {
return /400.*invalid token/.test(err.message);
return err.code == 400 && "invalid token" == err.message;
}));
});
@ -301,7 +301,7 @@ describe("loop.Client", function() {
sinon.assert.calledOnce(callback);
sinon.assert.calledWithExactly(callback, sinon.match(function(err) {
return /400.*invalid token/.test(err.message);
return err.code == 400 && "invalid token" == err.message;
}));
});

View File

@ -346,8 +346,8 @@ describe("loop.panel", function() {
expect(urlField.value).eql(callUrlData.callUrl);
});
it("should reset all pending notifications", function() {
sinon.assert.calledOnce(view.props.notifications.reset);
it("should have 0 pending notifications", function() {
expect(view.props.notifications.length).eql(0);
});
it("should display a share button for email", function() {

View File

@ -14,6 +14,34 @@ const {
const BASE_URL = "http://mochi.test:8888/browser/browser/components/loop/test/mochitest/loop_fxa.sjs?";
function* checkFxA401() {
let err = MozLoopService.errors.get("login");
ise(err.code, 401, "Check error code");
ise(err.friendlyMessage, getLoopString("could_not_authenticate"),
"Check friendlyMessage");
ise(err.friendlyDetails, getLoopString("password_changed_question"),
"Check friendlyDetails");
ise(err.friendlyDetailsButtonLabel, getLoopString("retry_button"),
"Check friendlyDetailsButtonLabel");
let loopButton = document.getElementById("loop-call-button");
is(loopButton.getAttribute("state"), "error",
"state of loop button should be error after a 401 with login");
let loopPanel = document.getElementById("loop-notification-panel");
yield loadLoopPanel({loopURL: BASE_URL });
let loopDoc = document.getElementById("loop").contentDocument;
is(loopDoc.querySelector(".alert-error .message").textContent,
getLoopString("could_not_authenticate"),
"Check error bar message");
is(loopDoc.querySelector(".details-error .details").textContent,
getLoopString("password_changed_question"),
"Check error bar details message");
is(loopDoc.querySelector(".details-error .detailsButton").textContent,
getLoopString("retry_button"),
"Check error bar details button");
loopPanel.hidePopup();
}
add_task(function* setup() {
Services.prefs.setCharPref("loop.server", BASE_URL);
Services.prefs.setCharPref("services.push.serverURL", "ws://localhost/");
@ -22,7 +50,7 @@ add_task(function* setup() {
yield promiseDeletedOAuthParams(BASE_URL);
Services.prefs.clearUserPref("loop.server");
Services.prefs.clearUserPref("services.push.serverURL");
resetFxA();
yield resetFxA();
Services.prefs.clearUserPref(MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.GUEST));
});
});
@ -52,14 +80,14 @@ add_task(function* basicAuthorization() {
});
add_task(function* sameOAuthClientForTwoCalls() {
resetFxA();
yield resetFxA();
let client1 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
let client2 = yield MozLoopServiceInternal.promiseFxAOAuthClient();
ise(client1, client2, "The same client should be returned");
});
add_task(function* paramsInvalid() {
resetFxA();
yield resetFxA();
// Delete the params so an empty object is returned.
yield promiseDeletedOAuthParams(BASE_URL);
let result = null;
@ -74,7 +102,7 @@ add_task(function* paramsInvalid() {
});
add_task(function* params_no_hawk_session() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -101,7 +129,7 @@ add_task(function* params_no_hawk_session() {
add_task(function* params_nonJSON() {
Services.prefs.setCharPref("loop.server", "https://loop.invalid");
// Reset after changing the server so a new HawkClient is created
resetFxA();
yield resetFxA();
let loginPromise = MozLoopService.logInToFxA();
let caught = false;
@ -114,7 +142,7 @@ add_task(function* params_nonJSON() {
});
add_task(function* invalidState() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -130,7 +158,7 @@ add_task(function* invalidState() {
});
add_task(function* basicRegistrationWithoutSession() {
resetFxA();
yield resetFxA();
yield promiseDeletedOAuthParams(BASE_URL);
let caught = false;
@ -139,6 +167,7 @@ add_task(function* basicRegistrationWithoutSession() {
is(error.code, 401, "Should have returned a 401");
});
ok(caught, "Should have caught the error requesting /token without a hawk session");
yield checkFxA401();
});
add_task(function* basicRegistration() {
@ -150,7 +179,7 @@ add_task(function* basicRegistration() {
state: "state",
};
yield promiseOAuthParamsSetup(BASE_URL, params);
resetFxA();
yield resetFxA();
// Create a fake FxA hawk session token
const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
Services.prefs.setCharPref(fxASessionPref, "X".repeat(HAWK_TOKEN_LENGTH));
@ -162,7 +191,7 @@ add_task(function* basicRegistration() {
});
add_task(function* registrationWithInvalidState() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -186,7 +215,7 @@ add_task(function* registrationWithInvalidState() {
});
add_task(function* registrationWith401() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -204,10 +233,12 @@ add_task(function* registrationWith401() {
error => {
is(error.code, 401, "Check error code");
});
yield checkFxA401();
});
add_task(function* basicAuthorizationAndRegistration() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -272,7 +303,7 @@ add_task(function* basicAuthorizationAndRegistration() {
});
add_task(function* loginWithParams401() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -292,10 +323,12 @@ add_task(function* loginWithParams401() {
ise(error.code, 401, "Check error code");
ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
});
yield checkFxA401();
});
add_task(function* logoutWithIncorrectPushURL() {
resetFxA();
yield resetFxA();
let pushURL = "http://www.example.com/";
mockPushHandler.pushUrl = pushURL;
@ -318,7 +351,7 @@ add_task(function* logoutWithIncorrectPushURL() {
});
add_task(function* logoutWithNoPushURL() {
resetFxA();
yield resetFxA();
let pushURL = "http://www.example.com/";
mockPushHandler.pushUrl = pushURL;
@ -330,18 +363,14 @@ add_task(function* logoutWithNoPushURL() {
let registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL");
mockPushHandler.pushUrl = null;
let caught = false;
yield MozLoopService.logOutFromFxA().catch((error) => {
caught = true;
});
ok(caught, "Should have caught an error logging out without a push URL");
yield MozLoopService.logOutFromFxA();
checkLoggedOutState();
registrationResponse = yield promiseOAuthGetRegistration(BASE_URL);
ise(registrationResponse.response.simplePushURL, pushURL, "Check registered push URL wasn't deleted");
});
add_task(function* loginWithRegistration401() {
resetFxA();
yield resetFxA();
let params = {
client_id: "client_id",
content_uri: BASE_URL + "/content",
@ -360,4 +389,6 @@ add_task(function* loginWithRegistration401() {
ise(error.code, 401, "Check error code");
ise(gFxAOAuthTokenData, null, "Check there is no saved token data");
});
yield checkFxA401();
});

View File

@ -19,10 +19,21 @@ function promiseGetMozLoopAPI() {
let loopPanel = document.getElementById("loop-notification-panel");
let btn = document.getElementById("loop-call-button");
// Wait for the popup to be shown, then we can get the iframe and
// Wait for the popup to be shown if it's not already, then we can get the iframe and
// wait for the iframe's load to be completed.
loopPanel.addEventListener("popupshown", function onpopupshown() {
loopPanel.removeEventListener("popupshown", onpopupshown, true);
if (loopPanel.state == "closing" || loopPanel.state == "closed") {
loopPanel.addEventListener("popupshown", () => {
loopPanel.removeEventListener("popupshown", onpopupshown, true);
onpopupshown();
}, true);
// Now we're setup, click the button.
btn.click();
} else {
setTimeout(onpopupshown, 0);
}
function onpopupshown() {
let iframe = document.getElementById(btn.getAttribute("notificationFrameId"));
if (iframe.contentDocument &&
@ -41,10 +52,7 @@ function promiseGetMozLoopAPI() {
deferred.resolve();
}, true);
}
}, true);
// Now we're setup, click the button.
btn.click();
}
// Remove the iframe after each test. This also avoids mochitest complaining
// about leaks on shutdown as we intentionally hold the iframe open for the
@ -107,7 +115,7 @@ function promiseOAuthParamsSetup(baseURL, params) {
return deferred.promise;
}
function resetFxA() {
function* resetFxA() {
let global = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
global.gHawkClient = null;
global.gFxAOAuthClientPromise = null;
@ -116,6 +124,10 @@ function resetFxA() {
global.gFxAOAuthProfile = null;
const fxASessionPref = MozLoopServiceInternal.getSessionTokenPrefName(LOOP_SESSION_TYPE.FXA);
Services.prefs.clearUserPref(fxASessionPref);
MozLoopService.errors.clear();
let notified = promiseObserverNotified("loop-status-changed");
MozLoopServiceInternal.notifyStatusChanged();
yield notified;
}
function setInternalLoopGlobal(aName, aValue) {
@ -172,6 +184,10 @@ function promiseOAuthGetRegistration(baseURL) {
return deferred.promise;
}
function getLoopString(stringID) {
return MozLoopServiceInternal.localizedStrings[stringID].textContent;
}
/**
* This is used to fake push registration and notifications for
* MozLoopService tests. There is only one object created per test instance, as

View File

@ -7,9 +7,8 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Http.jsm");
Cu.import("resource://testing-common/httpd.js");
XPCOMUtils.defineLazyModuleGetter(this, "MozLoopService",
"resource:///modules/loop/MozLoopService.jsm");
Cu.import("resource:///modules/loop/MozLoopService.jsm");
const { MozLoopServiceInternal } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
"resource:///modules/loop/MozLoopPushHandler.jsm");
@ -62,6 +61,10 @@ function waitForCondition(aConditionFn, aMaxTries=50, aCheckInterval=100) {
return deferred.promise;
}
function getLoopString(stringID) {
return MozLoopServiceInternal.localizedStrings[stringID].textContent;
}
/**
* This is used to fake push registration and notifications for
* MozLoopService tests. There is only one object created per test instance, as

View File

@ -0,0 +1,194 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Unit tests for the error handling for hawkRequest via setError.
*
* hawkRequest calls setError itself for 401. Consumers need to report other
* errors to setError themseleves.
*/
"use strict";
const { INVALID_AUTH_TOKEN } = Cu.import("resource:///modules/loop/MozLoopService.jsm");
/**
* An HTTP request for /NNN responds with a request with a status of NNN.
*/
function errorRequestHandler(request, response) {
let responseCode = request.path.substring(1);
response.setStatusLine(null, responseCode, "Error");
if (responseCode == 401) {
response.write(JSON.stringify({
code: parseInt(responseCode),
errno: INVALID_AUTH_TOKEN,
error: "INVALID_AUTH_TOKEN",
message: "INVALID_AUTH_TOKEN",
}));
}
}
add_task(function* setup_server() {
loopServer.registerPathHandler("/401", errorRequestHandler);
loopServer.registerPathHandler("/404", errorRequestHandler);
loopServer.registerPathHandler("/500", errorRequestHandler);
loopServer.registerPathHandler("/503", errorRequestHandler);
});
add_task(function* error_offline() {
Services.io.offline = true;
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/offline", "GET").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
MozLoopServiceInternal.setError("testing", error);
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
// Network errors are converted to the "network" errorType.
let err = MozLoopService.errors.get("network");
Assert.strictEqual(err.code, null);
Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_connect"));
Assert.strictEqual(err.friendlyDetails, getLoopString("check_internet_connection"));
Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
});
Services.io.offline = false;
});
add_task(cleanup_between_tests);
add_task(function* guest_401() {
Services.prefs.setCharPref("loop.hawk-session-token", "guest");
Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/401", "POST").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token"),
Services.prefs.PREF_INVALID,
"Guest session token should have been cleared");
Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token.fxa"),
"fxa",
"FxA session token should NOT have been cleared");
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
let err = MozLoopService.errors.get("registration");
Assert.strictEqual(err.code, 401);
Assert.strictEqual(err.friendlyMessage, getLoopString("session_expired_error_description"));
Assert.equal(err.friendlyDetails, null);
Assert.equal(err.friendlyDetailsButtonLabel, null);
});
});
add_task(cleanup_between_tests);
add_task(function* fxa_401() {
Services.prefs.setCharPref("loop.hawk-session-token", "guest");
Services.prefs.setCharPref("loop.hawk-session-token.fxa", "fxa");
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.FXA, "/401", "POST").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
Assert.strictEqual(Services.prefs.getCharPref("loop.hawk-session-token"),
"guest",
"Guest session token should NOT have been cleared");
Assert.strictEqual(Services.prefs.getPrefType("loop.hawk-session-token.fxa"),
Services.prefs.PREF_INVALID,
"Fxa session token should have been cleared");
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
let err = MozLoopService.errors.get("login");
Assert.strictEqual(err.code, 401);
Assert.strictEqual(err.friendlyMessage, getLoopString("could_not_authenticate"));
Assert.strictEqual(err.friendlyDetails, getLoopString("password_changed_question"));
Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
});
});
add_task(cleanup_between_tests);
add_task(function* error_404() {
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/404", "GET").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
MozLoopServiceInternal.setError("testing", error);
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
let err = MozLoopService.errors.get("testing");
Assert.strictEqual(err.code, 404);
Assert.strictEqual(err.friendlyMessage, getLoopString("generic_failure_title"));
Assert.equal(err.friendlyDetails, null);
Assert.equal(err.friendlyDetailsButtonLabel, null);
});
});
add_task(cleanup_between_tests);
add_task(function* error_500() {
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
MozLoopServiceInternal.setError("testing", error);
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
let err = MozLoopService.errors.get("testing");
Assert.strictEqual(err.code, 500);
Assert.strictEqual(err.friendlyMessage, getLoopString("service_not_available"));
Assert.strictEqual(err.friendlyDetails, getLoopString("try_again_later"));
Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
});
});
add_task(cleanup_between_tests);
add_task(function* profile_500() {
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/500", "GET").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
MozLoopServiceInternal.setError("profile", error);
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
let err = MozLoopService.errors.get("profile");
Assert.strictEqual(err.code, 500);
Assert.strictEqual(err.friendlyMessage, getLoopString("problem_accessing_account"));
Assert.equal(err.friendlyDetails, null);
Assert.equal(err.friendlyDetailsButtonLabel, null);
});
});
add_task(cleanup_between_tests);
add_task(function* error_503() {
yield MozLoopService.hawkRequest(LOOP_SESSION_TYPE.GUEST, "/503", "GET").then(
() => Assert.ok(false, "Should have rejected"),
(error) => {
MozLoopServiceInternal.setError("testing", error);
Assert.strictEqual(MozLoopService.errors.size, 1, "Should be one error");
let err = MozLoopService.errors.get("testing");
Assert.strictEqual(err.code, 503);
Assert.strictEqual(err.friendlyMessage, getLoopString("service_not_available"));
Assert.strictEqual(err.friendlyDetails, getLoopString("try_again_later"));
Assert.strictEqual(err.friendlyDetailsButtonLabel, getLoopString("retry_button"));
});
});
add_task(cleanup_between_tests);
function run_test() {
setupFakeLoopServer();
// Set the expiry time one hour in the future so that an error is shown when the guest session expires.
MozLoopServiceInternal.expiryTimeSeconds = (Date.now() / 1000) + 3600;
do_register_cleanup(() => {
Services.prefs.clearUserPref("loop.hawk-session-token");
Services.prefs.clearUserPref("loop.hawk-session-token.fxa");
Services.prefs.clearUserPref("loop.urlsExpiryTimeSeconds");
MozLoopService.errors.clear();
});
run_next_test();
}
function* cleanup_between_tests() {
MozLoopService.errors.clear();
Services.io.offline = false;
}

View File

@ -7,6 +7,7 @@ firefox-appdir = browser
[test_looppush_initialize.js]
[test_loopservice_dnd.js]
[test_loopservice_expiry.js]
[test_loopservice_hawk_errors.js]
[test_loopservice_loop_prefs.js]
[test_loopservice_initialize.js]
[test_loopservice_locales.js]

View File

@ -78,7 +78,12 @@
var notifications = new loop.shared.models.NotificationCollection();
var errNotifications = new loop.shared.models.NotificationCollection();
errNotifications.error("Error!");
errNotifications.add({
level: "error",
message: "Could Not Authenticate",
details: "Did you change your password?",
detailsButtonLabel: "Retry",
});
var Example = React.createClass({displayName: 'Example',
render: function() {

View File

@ -78,7 +78,12 @@
var notifications = new loop.shared.models.NotificationCollection();
var errNotifications = new loop.shared.models.NotificationCollection();
errNotifications.error("Error!");
errNotifications.add({
level: "error",
message: "Could Not Authenticate",
details: "Did you change your password?",
detailsButtonLabel: "Retry",
});
var Example = React.createClass({
render: function() {