/** * Copyright 2010-present Facebook. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.facebook; import android.app.Activity; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import com.facebook.model.GraphObject; import com.facebook.model.GraphObjectList; import com.facebook.internal.Logger; import com.facebook.internal.Utility; import com.facebook.internal.Validate; import org.json.JSONException; import org.json.JSONObject; import java.util.*; /** * Implements an subclass of Session that knows about test users for a particular * application. This should never be used from a real application, but may be useful * for writing unit tests, etc. * <p/> * Facebook allows developers to create test accounts for testing their applications' * Facebook integration (see https://developers.facebook.com/docs/test_users/). This class * simplifies use of these accounts for writing unit tests. It is not designed for use in * production application code. * <p/> * The main use case for this class is using {@link #createSessionWithPrivateUser(android.app.Activity, java.util.List)} * or {@link #createSessionWithSharedUser(android.app.Activity, java.util.List)} * to create a session for a test user. Two modes are supported. In "shared" mode, an attempt * is made to find an existing test user that has the required permissions. If no such user is available, * a new one is created with the required permissions. In "private" mode, designed for * scenarios which require a new user in a known clean state, a new test user will always be * created, and it will be automatically deleted when the TestSession is closed. The session * obeys the same lifecycle as a regular Session, meaning it must be opened after creation before * it can be used to make calls to the Facebook API. * <p/> * Prior to creating a TestSession, two static methods must be called to initialize the * application ID and application Secret to be used for managing test users. These methods are * {@link #setTestApplicationId(String)} and {@link #setTestApplicationSecret(String)}. * <p/> * Note that the shared test user functionality depends on a naming convention for the test users. * It is important that any testing of functionality which will mutate the permissions for a * test user NOT use a shared test user, or this scheme will break down. If a shared test user * seems to be in an invalid state, it can be deleted manually via the Web interface at * https://developers.facebook.com/apps/APP_ID/permissions?role=test+users. */ public class TestSession extends Session { private static final long serialVersionUID = 1L; private enum Mode { PRIVATE, SHARED } private static final String LOG_TAG = Logger.LOG_TAG_BASE + "TestSession"; private static Map<String, TestAccount> appTestAccounts; private static String testApplicationSecret; private static String testApplicationId; private final String sessionUniqueUserTag; private final List<String> requestedPermissions; private final Mode mode; private String testAccountId; private boolean wasAskedToExtendAccessToken; TestSession(Activity activity, List<String> permissions, TokenCachingStrategy tokenCachingStrategy, String sessionUniqueUserTag, Mode mode) { super(activity, TestSession.testApplicationId, tokenCachingStrategy); Validate.notNull(permissions, "permissions"); // Validate these as if they were arguments even though they are statics. Validate.notNullOrEmpty(testApplicationId, "testApplicationId"); Validate.notNullOrEmpty(testApplicationSecret, "testApplicationSecret"); this.sessionUniqueUserTag = sessionUniqueUserTag; this.mode = mode; this.requestedPermissions = permissions; } /** * Constructs a TestSession which creates a test user on open, and destroys the user on * close; This method should not be used in application code -- but is useful for creating unit tests * that use the Facebook SDK. * * @param activity the Activity to use for opening the session * @param permissions list of strings containing permissions to request; nil will result in * a common set of permissions (email, publish_actions) being requested * @return a new TestSession that is in the CREATED state, ready to be opened */ public static TestSession createSessionWithPrivateUser(Activity activity, List<String> permissions) { return createTestSession(activity, permissions, Mode.PRIVATE, null); } /** * Constructs a TestSession which uses a shared test user with the right permissions, * creating one if necessary on open (but not deleting it on close, so it can be re-used in later * tests). * <p/> * This method should not be used in application code -- but is useful for creating unit tests * that use the Facebook SDK. * * @param activity the Activity to use for opening the session * @param permissions list of strings containing permissions to request; nil will result in * a common set of permissions (email, publish_actions) being requested * @return a new TestSession that is in the CREATED state, ready to be opened */ public static TestSession createSessionWithSharedUser(Activity activity, List<String> permissions) { return createSessionWithSharedUser(activity, permissions, null); } /** * Constructs a TestSession which uses a shared test user with the right permissions, * creating one if necessary on open (but not deleting it on close, so it can be re-used in later * tests). * <p/> * This method should not be used in application code -- but is useful for creating unit tests * that use the Facebook SDK. * * @param activity the Activity to use for opening the session * @param permissions list of strings containing permissions to request; nil will result in * a common set of permissions (email, publish_actions) being requested * @param sessionUniqueUserTag a string which will be used to make this user unique among other * users with the same permissions. Useful for tests which require two or more users to interact * with each other, and which therefore must have sessions associated with different users. * @return a new TestSession that is in the CREATED state, ready to be opened */ public static TestSession createSessionWithSharedUser(Activity activity, List<String> permissions, String sessionUniqueUserTag) { return createTestSession(activity, permissions, Mode.SHARED, sessionUniqueUserTag); } /** * Gets the Facebook Application ID for the application under test. * * @return the application ID */ public static synchronized String getTestApplicationId() { return testApplicationId; } /** * Sets the Facebook Application ID for the application under test. This must be specified * prior to creating a TestSession. * * @param applicationId the application ID */ public static synchronized void setTestApplicationId(String applicationId) { if (testApplicationId != null && !testApplicationId.equals(applicationId)) { throw new FacebookException("Can't have more than one test application ID"); } testApplicationId = applicationId; } /** * Gets the Facebook Application Secret for the application under test. * * @return the application secret */ public static synchronized String getTestApplicationSecret() { return testApplicationSecret; } /** * Sets the Facebook Application Secret for the application under test. This must be specified * prior to creating a TestSession. * * @param applicationSecret the application secret */ public static synchronized void setTestApplicationSecret(String applicationSecret) { if (testApplicationSecret != null && !testApplicationSecret.equals(applicationSecret)) { throw new FacebookException("Can't have more than one test application secret"); } testApplicationSecret = applicationSecret; } /** * Gets the ID of the test user that this TestSession is authenticated as. * * @return the Facebook user ID of the test user */ public final String getTestUserId() { return testAccountId; } private static synchronized TestSession createTestSession(Activity activity, List<String> permissions, Mode mode, String sessionUniqueUserTag) { if (Utility.isNullOrEmpty(testApplicationId) || Utility.isNullOrEmpty(testApplicationSecret)) { throw new FacebookException("Must provide app ID and secret"); } if (Utility.isNullOrEmpty(permissions)) { permissions = Arrays.asList("email", "publish_actions"); } return new TestSession(activity, permissions, new TestTokenCachingStrategy(), sessionUniqueUserTag, mode); } private static synchronized void retrieveTestAccountsForAppIfNeeded() { if (appTestAccounts != null) { return; } appTestAccounts = new HashMap<String, TestAccount>(); // The data we need is split across two different FQL tables. We construct two queries, submit them // together (the second one refers to the first one), then cross-reference the results. // Get the test accounts for this app. String testAccountQuery = String.format("SELECT id,access_token FROM test_account WHERE app_id = %s", testApplicationId); // Get the user names for those accounts. String userQuery = "SELECT uid,name FROM user WHERE uid IN (SELECT id FROM #test_accounts)"; Bundle parameters = new Bundle(); // Build a JSON string that contains our queries and pass it as the 'q' parameter of the query. JSONObject multiquery; try { multiquery = new JSONObject(); multiquery.put("test_accounts", testAccountQuery); multiquery.put("users", userQuery); } catch (JSONException exception) { throw new FacebookException(exception); } parameters.putString("q", multiquery.toString()); // We need to authenticate as this app. parameters.putString("access_token", getAppAccessToken()); Request request = new Request(null, "fql", parameters, null); Response response = request.executeAndWait(); if (response.getError() != null) { throw response.getError().getException(); } FqlResponse fqlResponse = response.getGraphObjectAs(FqlResponse.class); GraphObjectList<FqlResult> fqlResults = fqlResponse.getData(); if (fqlResults == null || fqlResults.size() != 2) { throw new FacebookException("Unexpected number of results from FQL query"); } // We get back two sets of results. The first is from the test_accounts query, the second from the users query. Collection<TestAccount> testAccounts = fqlResults.get(0).getFqlResultSet().castToListOf(TestAccount.class); Collection<UserAccount> userAccounts = fqlResults.get(1).getFqlResultSet().castToListOf(UserAccount.class); // Use both sets of results to populate our static array of accounts. populateTestAccounts(testAccounts, userAccounts); return; } private static synchronized void populateTestAccounts(Collection<TestAccount> testAccounts, Collection<UserAccount> userAccounts) { // We get different sets of data from each of these queries. We want to combine them into a single data // structure. We have added a Name property to the TestAccount interface, even though we don't really get // a name back from the service from that query. We stick the Name from the corresponding UserAccount in it. for (TestAccount testAccount : testAccounts) { storeTestAccount(testAccount); } for (UserAccount userAccount : userAccounts) { TestAccount testAccount = appTestAccounts.get(userAccount.getUid()); if (testAccount != null) { testAccount.setName(userAccount.getName()); } } } private static synchronized void storeTestAccount(TestAccount testAccount) { appTestAccounts.put(testAccount.getId(), testAccount); } private static synchronized TestAccount findTestAccountMatchingIdentifier(String identifier) { retrieveTestAccountsForAppIfNeeded(); for (TestAccount testAccount : appTestAccounts.values()) { if (testAccount.getName().contains(identifier)) { return testAccount; } } return null; } @Override public final String toString() { String superString = super.toString(); return new StringBuilder().append("{TestSession").append(" testUserId:").append(testAccountId) .append(" ").append(superString).append("}").toString(); } @Override void authorize(AuthorizationRequest request) { if (mode == Mode.PRIVATE) { createTestAccountAndFinishAuth(); } else { findOrCreateSharedTestAccount(); } } @Override void postStateChange(final SessionState oldState, final SessionState newState, final Exception error) { // Make sure this doesn't get overwritten. String id = testAccountId; super.postStateChange(oldState, newState, error); if (newState.isClosed() && id != null && mode == Mode.PRIVATE) { deleteTestAccount(id, getAppAccessToken()); } } boolean getWasAskedToExtendAccessToken() { return wasAskedToExtendAccessToken; } void forceExtendAccessToken(boolean forceExtendAccessToken) { AccessToken currentToken = getTokenInfo(); setTokenInfo( new AccessToken(currentToken.getToken(), new Date(), currentToken.getPermissions(), AccessTokenSource.TEST_USER, new Date(0))); setLastAttemptedTokenExtendDate(new Date(0)); } @Override boolean shouldExtendAccessToken() { boolean result = super.shouldExtendAccessToken(); wasAskedToExtendAccessToken = false; return result; } @Override void extendAccessToken() { wasAskedToExtendAccessToken = true; super.extendAccessToken(); } void fakeTokenRefreshAttempt() { setCurrentTokenRefreshRequest(new TokenRefreshRequest()); } static final String getAppAccessToken() { return testApplicationId + "|" + testApplicationSecret; } private void findOrCreateSharedTestAccount() { TestAccount testAccount = findTestAccountMatchingIdentifier(getSharedTestAccountIdentifier()); if (testAccount != null) { finishAuthWithTestAccount(testAccount); } else { createTestAccountAndFinishAuth(); } } private void finishAuthWithTestAccount(TestAccount testAccount) { testAccountId = testAccount.getId(); AccessToken accessToken = AccessToken.createFromString(testAccount.getAccessToken(), requestedPermissions, AccessTokenSource.TEST_USER); finishAuthOrReauth(accessToken, null); } private TestAccount createTestAccountAndFinishAuth() { Bundle parameters = new Bundle(); parameters.putString("installed", "true"); parameters.putString("permissions", getPermissionsString()); parameters.putString("access_token", getAppAccessToken()); // If we're in shared mode, we want to rename this user to encode its permissions, so we can find it later // in another shared session. If we're in private mode, don't bother renaming it since we're just going to // delete it at the end of the session. if (mode == Mode.SHARED) { parameters.putString("name", String.format("Shared %s Testuser", getSharedTestAccountIdentifier())); } String graphPath = String.format("%s/accounts/test-users", testApplicationId); Request createUserRequest = new Request(null, graphPath, parameters, HttpMethod.POST); Response response = createUserRequest.executeAndWait(); FacebookRequestError error = response.getError(); TestAccount testAccount = response.getGraphObjectAs(TestAccount.class); if (error != null) { finishAuthOrReauth(null, error.getException()); return null; } else { assert testAccount != null; // If we are in shared mode, store this new account in the dictionary so we can re-use it later. if (mode == Mode.SHARED) { // Remember the new name we gave it, since we didn't get it back in the results of the create request. testAccount.setName(parameters.getString("name")); storeTestAccount(testAccount); } finishAuthWithTestAccount(testAccount); return testAccount; } } private void deleteTestAccount(String testAccountId, String appAccessToken) { Bundle parameters = new Bundle(); parameters.putString("access_token", appAccessToken); Request request = new Request(null, testAccountId, parameters, HttpMethod.DELETE); Response response = request.executeAndWait(); FacebookRequestError error = response.getError(); GraphObject graphObject = response.getGraphObject(); if (error != null) { Log.w(LOG_TAG, String.format("Could not delete test account %s: %s", testAccountId, error.getException().toString())); } else if (graphObject.getProperty(Response.NON_JSON_RESPONSE_PROPERTY) == (Boolean) false) { Log.w(LOG_TAG, String.format("Could not delete test account %s: unknown reason", testAccountId)); } } private String getPermissionsString() { return TextUtils.join(",", requestedPermissions); } private String getSharedTestAccountIdentifier() { // We use long even though hashes are ints to avoid sign issues. long permissionsHash = getPermissionsString().hashCode() & 0xffffffffL; long sessionTagHash = (sessionUniqueUserTag != null) ? sessionUniqueUserTag.hashCode() & 0xffffffffL : 0; long combinedHash = permissionsHash ^ sessionTagHash; return validNameStringFromInteger(combinedHash); } private String validNameStringFromInteger(long i) { String s = Long.toString(i); StringBuilder result = new StringBuilder("Perm"); // We know each character is a digit. Convert it into a letter 'a'-'j'. Avoid repeated characters // that might make Facebook reject the name by converting every other repeated character into one // 10 higher ('k'-'t'). char lastChar = 0; for (char c : s.toCharArray()) { if (c == lastChar) { c += 10; } result.append((char) (c + 'a' - '0')); lastChar = c; } return result.toString(); } private interface TestAccount extends GraphObject { String getId(); String getAccessToken(); // Note: We don't actually get Name from our FQL query. We fill it in by correlating with UserAccounts. String getName(); void setName(String name); } private interface UserAccount extends GraphObject { String getUid(); String getName(); void setName(String name); } private interface FqlResult extends GraphObject { GraphObjectList<GraphObject> getFqlResultSet(); } private interface FqlResponse extends GraphObject { GraphObjectList<FqlResult> getData(); } private static final class TestTokenCachingStrategy extends TokenCachingStrategy { private Bundle bundle; @Override public Bundle load() { return bundle; } @Override public void save(Bundle value) { bundle = value; } @Override public void clear() { bundle = null; } } }