Bug 1139657 - Expose a method on FxAccounts for retrieving profile information. r=markh

This commit is contained in:
Zachary Carter 2015-03-26 01:33:38 -07:00
parent 116d5cd06c
commit a4437315a6
11 changed files with 689 additions and 12 deletions

View File

@ -11,7 +11,7 @@
detail: {
id: "account_updates",
message: {
command: "profile:image:change",
command: "profile:change",
data: {
uid: "abc123",
},

View File

@ -34,7 +34,7 @@ let gTests = [
content_uri: HTTP_PATH,
});
makeObserver(FxAccountsCommon.ONPROFILE_IMAGE_CHANGE_NOTIFICATION, function (subject, topic, data) {
makeObserver(FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
Assert.ok(tabOpened);
Assert.equal(data, "abc123");
resolve();

View File

@ -26,6 +26,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsOAuthGrantClient",
"resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfile",
"resource://gre/modules/FxAccountsProfile.jsm");
// All properties exposed by the public FxAccounts API.
let publicProperties = [
"accountStatus",
@ -36,6 +39,7 @@ let publicProperties = [
"getKeys",
"getSignedInUser",
"getOAuthToken",
"getSignedInUserProfile",
"loadAndPoll",
"localtimeOffsetMsec",
"now",
@ -75,6 +79,7 @@ AccountState.prototype = {
signedInUser: null,
whenVerifiedDeferred: null,
whenKeysReadyDeferred: null,
profile: null,
get isCurrent() this.fxaInternal && this.fxaInternal.currentAccountState === this,
@ -90,10 +95,17 @@ AccountState.prototype = {
new Error("Verification aborted; Another user signing in"));
this.whenKeysReadyDeferred = null;
}
this.cert = null;
this.keyPair = null;
this.signedInUser = null;
this.fxaInternal = null;
this.initProfilePromise = null;
if (this.profile) {
this.profile.tearDown();
this.profile = null;
}
},
getUserAccountData: function() {
@ -204,6 +216,41 @@ AccountState.prototype = {
return d.promise.then(result => this.resolve(result));
},
// Get the account's profile image URL from the profile server
getProfile: function () {
return this.initProfile()
.then(() => this.profile.getProfile());
},
// Instantiate a FxAccountsProfile with a fresh OAuth token if needed
initProfile: function () {
let profileServerUrl = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.profile.uri");
let oAuthOptions = {
scope: "profile"
};
if (this.initProfilePromise) {
return this.initProfilePromise;
}
this.initProfilePromise = this.fxaInternal.getOAuthToken(oAuthOptions)
.then(token => {
this.profile = new FxAccountsProfile(this, {
profileServerUrl: profileServerUrl,
token: token
});
this.initProfilePromise = null;
})
.then(null, err => {
this.initProfilePromise = null;
throw err;
});
return this.initProfilePromise;
},
resolve: function(result) {
if (!this.isCurrent) {
log.info("An accountState promise was resolved, but was actually rejected" +
@ -228,7 +275,7 @@ AccountState.prototype = {
return Promise.reject(error);
},
}
};
/**
* Copies properties from a given object to another object.
@ -989,9 +1036,9 @@ FxAccountsInternal.prototype = {
let error = SERVER_ERRNO_TO_ERROR[aError.errno];
return this._error(ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN, aError);
} else if (aError.message &&
aError.message === "INVALID_PARAMETER" ||
(aError.message === "INVALID_PARAMETER" ||
aError.message === "NO_ACCOUNT" ||
aError.message === "UNVERIFIED_ACCOUNT") {
aError.message === "UNVERIFIED_ACCOUNT")) {
return Promise.reject(aError);
}
return this._error(ERROR_UNKNOWN, aError);
@ -1004,7 +1051,60 @@ FxAccountsInternal.prototype = {
reason.details = aDetails;
}
return Promise.reject(reason);
}
},
/**
* Get the user's account and profile data
*
* @param options
* {
* contentUrl: (string) Used by the FxAccountsProfileChannel.
* Defaults to pref identity.fxaccounts.settings.uri
* profileServerUrl: (string) Used by the FxAccountsProfileChannel.
* Defaults to pref identity.fxaccounts.remote.profile.uri
* }
*
* @return Promise.<object | Error>
* The promise resolves to an accountData object with extra profile
* information such as profileImageUrl, or rejects with
* an error object ({error: ERROR, details: {}}) of the following:
* INVALID_PARAMETER
* NO_ACCOUNT
* UNVERIFIED_ACCOUNT
* NETWORK_ERROR
* AUTH_ERROR
* UNKNOWN_ERROR
*/
getSignedInUserProfile: function () {
let accountState = this.currentAccountState;
return accountState.getProfile()
.then(
(profileData) => {
let profile = JSON.parse(JSON.stringify(profileData));
// profileData doesn't include "verified", but it must be true
// if we've gotten this far.
profile.verified = true;
return accountState.resolve(profile);
},
(error) => {
log.error("Could not retrieve profile data", error);
return this.getSignedInUser().then(data => {
let profile = null;
if (data) {
// If we fail to fetch the profile and have no profile cached
// we resort to using the account data for basic profile data.
profile = {
email: data.email,
uid: data.uid,
verified: data.verified
};
}
return accountState.resolve(profile);
});
})
.then(null, err => this._errorToErrorClass(err));
},
};
/**

View File

@ -90,7 +90,7 @@ exports.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
// Internal to services/fxaccounts only
exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update";
exports.ONPROFILE_IMAGE_CHANGE_NOTIFICATION = "fxaccounts:profileimagechange";
exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange";
// UI Requests.
exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";

View File

@ -0,0 +1,165 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* Firefox Accounts Profile helper.
*
* This class abstracts interaction with the profile server for an account.
* It will handle things like fetching profile data, listening for updates to
* the user's profile in open browser tabs, and cacheing/invalidating profile data.
*/
this.EXPORTED_SYMBOLS = ["FxAccountsProfile"];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileClient",
"resource://gre/modules/FxAccountsProfileClient.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FxAccountsProfileChannel",
"resource://gre/modules/FxAccountsProfileChannel.jsm");
let fxAccountProfileChannel = null;
// Based off of deepEqual from Assert.jsm
function deepEqual(actual, expected) {
if (actual === expected) {
return true;
} else if (typeof actual != "object" && typeof expected != "object") {
return actual == expected;
} else {
return objEquiv(actual, expected);
}
}
function isUndefinedOrNull(value) {
return value === null || value === undefined;
}
function objEquiv(a, b) {
if (isUndefinedOrNull(a) || isUndefinedOrNull(b)) {
return false;
}
if (a.prototype !== b.prototype) {
return false;
}
let ka, kb, key, i;
try {
ka = Object.keys(a);
kb = Object.keys(b);
} catch (e) {
return false;
}
if (ka.length != kb.length) {
return false;
}
ka.sort();
kb.sort();
for (i = ka.length - 1; i >= 0; i--) {
key = ka[i];
if (!deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
function hasChanged(oldData, newData) {
return !deepEqual(oldData, newData);
}
this.FxAccountsProfile = function (accountState, options = {}) {
this.currentAccountState = accountState;
this.client = options.profileClient || new FxAccountsProfileClient({
serverURL: options.profileServerUrl,
token: options.token
});
// for testing
if (options.channel) {
this.channel = options.channel;
}
}
this.FxAccountsProfile.prototype = {
tearDown: function () {
this.currentAccountState = null;
this.client = null;
},
_getCachedProfile: function () {
let currentState = this.currentAccountState;
return currentState.getUserAccountData()
.then(cachedData => cachedData.profile);
},
_notifyProfileChange: function (uid) {
Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, uid);
},
// Cache fetched data if it is different from what's in the cache.
// Send out a notification if it has changed so that UI can update.
_cacheProfile: function (profileData) {
let currentState = this.currentAccountState;
if (!currentState) {
return;
}
return currentState.getUserAccountData()
.then(data => {
if (!hasChanged(data.profile, profileData)) {
return;
}
data.profile = profileData;
return currentState.setUserAccountData(data)
.then(() => this._notifyProfileChange(data.uid));
});
},
_fetchAndCacheProfile: function () {
return this.client.fetchProfile()
.then(profile => {
return this._cacheProfile(profile).then(() => profile);
});
},
// Initialize a profile channel to listen for account changes.
_listenForProfileChanges: function () {
if (! fxAccountProfileChannel) {
let contentUri = Services.urlFormatter.formatURLPref("identity.fxaccounts.settings.uri");
fxAccountProfileChannel = new FxAccountsProfileChannel({
content_uri: contentUri
});
}
return fxAccountProfileChannel;
},
// Returns cached data right away if available, then fetches the latest profile
// data in the background. After data is fetched a notification will be sent
// out if the profile has changed.
getProfile: function () {
this._listenForProfileChanges();
return this._getCachedProfile()
.then(cachedProfile => {
if (cachedProfile) {
this._fetchAndCacheProfile();
return cachedProfile;
}
return this._fetchAndCacheProfile();
})
.then(profile => {
return profile;
});
},
};

View File

@ -19,7 +19,7 @@ Cu.import("resource://gre/modules/FxAccountsCommon.js");
XPCOMUtils.defineLazyModuleGetter(this, "WebChannel",
"resource://gre/modules/WebChannel.jsm");
const PROFILE_IMAGE_CHANGE_COMMAND = "profile:image:change";
const PROFILE_CHANGE_COMMAND = "profile:change";
/**
* Create a new FxAccountsProfileChannel to listen to profile updates
@ -102,8 +102,8 @@ this.FxAccountsProfileChannel.prototype = {
let command = message.command;
let data = message.data;
switch (command) {
case PROFILE_IMAGE_CHANGE_COMMAND:
Services.obs.notifyObservers(null, ONPROFILE_IMAGE_CHANGE_NOTIFICATION, data.uid);
case PROFILE_CHANGE_COMMAND:
Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid);
break;
}
}

View File

@ -16,6 +16,7 @@ EXTRA_JS_MODULES += [
'FxAccountsCommon.js',
'FxAccountsOAuthClient.jsm',
'FxAccountsOAuthGrantClient.jsm',
'FxAccountsProfile.jsm',
'FxAccountsProfileChannel.jsm',
'FxAccountsProfileClient.jsm',
]

View File

@ -32,6 +32,13 @@ Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://exam
Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
const PROFILE_SERVER_URL = "http://example.com/v1";
const CONTENT_URL = "http://accounts.example.com/";
Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", PROFILE_SERVER_URL);
Services.prefs.setCharPref("identity.fxaccounts.settings.uri", CONTENT_URL);
function run_test() {
run_next_test();
}
@ -855,6 +862,123 @@ add_test(function test_getOAuthToken_unknown_error() {
});
});
add_test(function test_accountState_initProfile() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
fxa.internal.getOAuthToken = function (opts) {
return Promise.resolve("token");
};
fxa.setSignedInUser(alice).then(() => {
let accountState = fxa.internal.currentAccountState;
accountState.initProfile(options)
.then(result => {
do_check_true(!!accountState.profile);
run_next_test();
});
});
});
add_test(function test_accountState_getProfile() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
let mockProfile = {
getProfile: function () {
return Promise.resolve({ avatar: "image" });
}
};
fxa.setSignedInUser(alice).then(() => {
let accountState = fxa.internal.currentAccountState;
accountState.profile = mockProfile;
accountState.initProfilePromise = new Promise((resolve, reject) => resolve(mockProfile));
accountState.getProfile()
.then(result => {
do_check_true(!!result);
do_check_eq(result.avatar, "image");
run_next_test();
});
});
});
add_test(function test_getSignedInUserProfile_ok() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
fxa.setSignedInUser(alice).then(() => {
let accountState = fxa.internal.currentAccountState;
accountState.getProfile = function () {
return Promise.resolve({ avatar: "image" });
};
fxa.getSignedInUserProfile()
.then(result => {
do_check_eq(result.avatar, "image");
do_check_true(result.verified);
run_next_test();
});
});
});
add_test(function test_getSignedInUserProfile_error_uses_account_data() {
let fxa = new MockFxAccounts();
let alice = getTestUser("alice");
alice.verified = true;
fxa.internal.getSignedInUser = function () {
return Promise.resolve({ email: "foo@bar.com" });
};
fxa.setSignedInUser(alice).then(() => {
let accountState = fxa.internal.currentAccountState;
accountState.getProfile = function () {
return Promise.reject("boom");
};
fxa.getSignedInUserProfile()
.then(result => {
do_check_eq(typeof result.avatar, "undefined");
do_check_eq(result.email, "foo@bar.com");
run_next_test();
});
});
});
add_test(function test_getSignedInUserProfile_no_account_data() {
let fxa = new MockFxAccounts();
fxa.internal.getSignedInUser = function () {
return Promise.resolve({ email: "foo@bar.com" });
};
let accountState = fxa.internal.currentAccountState;
accountState.getProfile = function () {
return Promise.reject("boom");
};
fxa.internal.getSignedInUser = function () {
return Promise.resolve(null);
};
fxa.getSignedInUserProfile()
.then(result => {
do_check_eq(result, null);
run_next_test();
});
});
/*
* End of tests.
* Utility functions follow.

View File

@ -0,0 +1,286 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm");
Cu.import("resource://gre/modules/FxAccountsProfile.jsm");
const URL_STRING = "https://example.com";
Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "https://example.com/settings");
const PROFILE_CLIENT_OPTIONS = {
token: "123ABC",
serverURL: "http://127.0.0.1:1111/v1",
profileServerUrl: "http://127.0.0.1:1111/v1"
};
const STATUS_SUCCESS = 200;
/**
* Mock request responder
* @param {String} response
* Mocked raw response from the server
* @returns {Function}
*/
let mockResponse = function (response) {
let Request = function (requestUri) {
// Store the request uri so tests can inspect it
Request._requestUri = requestUri;
return {
setHeader: function () {},
head: function () {
this.response = response;
this.onComplete();
}
};
};
return Request;
};
/**
* Mock request error responder
* @param {Error} error
* Error object
* @returns {Function}
*/
let mockResponseError = function (error) {
return function () {
return {
setHeader: function () {},
head: function () {
this.onComplete(error);
}
};
};
};
let mockClient = function () {
let client = new FxAccountsProfileClient(PROFILE_CLIENT_OPTIONS);
return client;
};
const ACCOUNT_DATA = {
uid: "abc123"
};
function AccountData () {
}
AccountData.prototype = {
getUserAccountData: function () {
return Promise.resolve(ACCOUNT_DATA);
}
};
let mockAccountData = function () {
return new AccountData();
};
add_test(function getCachedProfile() {
let accountData = mockAccountData();
accountData.getUserAccountData = function () {
return Promise.resolve({
profile: { avatar: "myurl" }
});
};
let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
return profile._getCachedProfile()
.then(function (cached) {
do_check_eq(cached.avatar, "myurl");
run_next_test();
});
});
add_test(function cacheProfile_change() {
let accountData = mockAccountData();
let setUserAccountDataCalled = false;
accountData.setUserAccountData = function (data) {
setUserAccountDataCalled = true;
do_check_eq(data.profile.avatar, "myurl");
return Promise.resolve();
};
let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
do_check_eq(data, ACCOUNT_DATA.uid);
do_check_true(setUserAccountDataCalled);
run_next_test();
});
return profile._cacheProfile({ avatar: "myurl" });
});
add_test(function cacheProfile_no_change() {
let accountData = mockAccountData();
accountData.getUserAccountData = function () {
return Promise.resolve({
profile: { avatar: "myurl" }
});
};
accountData.setUserAccountData = function (data) {
throw new Error("should not update account data");
};
let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
return profile._cacheProfile({ avatar: "myurl" })
.then((result) => {
do_check_false(!!result);
run_next_test();
});
});
add_test(function fetchAndCacheProfile_ok() {
let client = mockClient();
client.fetchProfile = function () {
return Promise.resolve({ avatar: "myimg"});
};
let profile = new FxAccountsProfile(mockAccountData(), {
profileClient: client
});
profile._cacheProfile = function (toCache) {
do_check_eq(toCache.avatar, "myimg");
return Promise.resolve();
};
return profile._fetchAndCacheProfile()
.then(result => {
do_check_eq(result.avatar, "myimg");
run_next_test();
});
});
add_test(function profile_channel() {
let profile = new FxAccountsProfile(mockAccountData(), PROFILE_CLIENT_OPTIONS);
let channel = profile._listenForProfileChanges();
do_check_true(!!channel);
let channel2 = profile._listenForProfileChanges();
do_check_eq(channel, channel2);
run_next_test();
});
add_test(function tearDown_ok() {
let profile = new FxAccountsProfile(mockAccountData(), PROFILE_CLIENT_OPTIONS);
do_check_true(!!profile.client);
do_check_true(!!profile.currentAccountState);
profile.tearDown();
do_check_null(profile.currentAccountState);
do_check_null(profile.client);
run_next_test();
});
add_test(function getProfile_ok() {
let cachedUrl = "myurl";
let accountData = mockAccountData();
let didFetch = false;
let didListen = false;
let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
profile._getCachedProfile = function () {
return Promise.resolve({ avatar: cachedUrl });
};
profile._fetchAndCacheProfile = function () {
didFetch = true;
};
profile._listenForProfileChanges = function () {
didListen = true;
};
return profile.getProfile()
.then(result => {
do_check_eq(result.avatar, cachedUrl);
do_check_true(didFetch);
do_check_true(didListen);
run_next_test();
});
});
add_test(function getProfile_no_cache() {
let fetchedUrl = "newUrl";
let accountData = mockAccountData();
let didListen = false;
let profile = new FxAccountsProfile(accountData, PROFILE_CLIENT_OPTIONS);
profile._getCachedProfile = function () {
return Promise.resolve();
};
profile._fetchAndCacheProfile = function () {
return Promise.resolve({ avatar: fetchedUrl });
};
profile._listenForProfileChanges = function () {
didListen = true;
};
return profile.getProfile()
.then(result => {
do_check_eq(result.avatar, fetchedUrl);
do_check_true(didListen);
run_next_test();
});
});
add_test(function getProfile_has_cached_fetch_deleted() {
let cachedUrl = "myurl";
let didFetch = false;
let didListen = false;
let client = mockClient();
client.fetchProfile = function () {
return Promise.resolve({ avatar: null });
};
let accountData = mockAccountData();
accountData.getUserAccountData = function () {
return Promise.resolve({ profile: { avatar: cachedUrl } });
};
accountData.setUserAccountData = function (data) {
do_check_null(data.profile.avatar);
run_next_test();
return Promise.resolve();
};
let profile = new FxAccountsProfile(accountData, {
profileClient: client
});
return profile.getProfile()
.then(result => {
do_check_eq(result.avatar, "myurl");
});
});
function run_test() {
run_next_test();
}
function makeObserver(aObserveTopic, aObserveFunc) {
let callback = function (aSubject, aTopic, aData) {
log.debug("observed " + aTopic + " " + aData);
if (aTopic == aObserveTopic) {
removeMe();
aObserveFunc(aSubject, aTopic, aData);
}
};
function removeMe() {
log.debug("removing observer for " + aObserveTopic);
Services.obs.removeObserver(callback, aObserveTopic);
}
Services.obs.addObserver(callback, aObserveTopic, false);
return removeMe;
}

View File

@ -23,11 +23,11 @@ add_test(function () {
add_test(function () {
var mockMessage = {
command: "profile:image:change",
command: "profile:change",
data: { uid: "foo" }
};
makeObserver(ONPROFILE_IMAGE_CHANGE_NOTIFICATION, function (subject, topic, data) {
makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
do_check_eq(data, "foo");
run_next_test();
});

View File

@ -16,3 +16,4 @@ reason = FxAccountsManager is only available for B2G for now
[test_oauth_grant_client.js]
[test_profile_client.js]
[test_profile_channel.js]
[test_profile.js]