From da4dc1b3b832e30cabdfed331116d7a2a92dd5d0 Mon Sep 17 00:00:00 2001 From: David Joy Date: Tue, 9 Jun 2020 11:45:27 -0400 Subject: [PATCH] feat: Adding a MockAuthService, improving docs (#92) The mock auth service uses axios-mock-adapter in its implementation to allow mocking of any http client calls the auth service might make on behalf of an application. This PR also fleshes out the docs for the auth service to try to add some detail around the differences between the interface, AxiosJwtAuthService, and new MockAuthService implementations. --- package-lock.json | 21 +-- package.json | 2 +- src/auth/AxiosJwtAuthService.js | 10 +- src/auth/MockAuthService.js | 275 ++++++++++++++++++++++++++++++++ src/auth/index.js | 1 + src/auth/interface.js | 50 ++++++ src/config.js | 1 + src/utils.js | 1 + 8 files changed, 339 insertions(+), 22 deletions(-) create mode 100644 src/auth/MockAuthService.js diff --git a/package-lock.json b/package-lock.json index 64dc7c140..ea8a6eb47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3007,7 +3007,6 @@ "version": "1.17.0", "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.17.0.tgz", "integrity": "sha512-q3efmwJUOO4g+wsLNSk9Ps1UlJoF3fQ3FSEe4uEEhkRtu7SoiAVPj8R3Hc/WP55MBTVFzaDP9QkdJhdVhP8A1Q==", - "dev": true, "requires": { "deep-equal": "^1.0.1" } @@ -5721,7 +5720,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.0.tgz", "integrity": "sha512-ZbfWJq/wN1Z273o7mUSjILYqehAktR2NVoSrOukDkU9kg2v/Uv89yU4Cvz8seJeAmtN5oqiefKq8FPuXOboqLw==", - "dev": true, "requires": { "is-arguments": "^1.0.4", "is-date-object": "^1.0.1", @@ -5763,7 +5761,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "dev": true, "requires": { "object-keys": "^1.0.12" } @@ -8820,8 +8817,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "function.prototype.name": { "version": "1.1.1", @@ -9233,7 +9229,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -10352,8 +10347,7 @@ "is-arguments": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", - "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==", - "dev": true + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" }, "is-arrayish": { "version": "0.2.1", @@ -10443,8 +10437,7 @@ "is-date-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", - "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", - "dev": true + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" }, "is-descriptor": { "version": "0.1.6", @@ -10694,7 +10687,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", - "dev": true, "requires": { "has": "^1.0.1" } @@ -13466,14 +13458,12 @@ "object-is": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz", - "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=", - "dev": true + "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=" }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, "object-path": { "version": "0.11.4", @@ -15848,7 +15838,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.2.0.tgz", "integrity": "sha512-ztaw4M1VqgMwl9HlPpOuiYgItcHlunW0He2fE6eNfT6E/CF2FtYi9ofOYe4mKntstYk0Fyh/rDRBdS3AnxjlrA==", - "dev": true, "requires": { "define-properties": "^1.1.2" } diff --git a/package.json b/package.json index fd38f80eb..bd506da70 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@commitlint/config-angular": "8.2.0", "@edx/frontend-build": "2.0.6", "@edx/paragon": "7.1.5", - "axios-mock-adapter": "1.17.0", "babel-polyfill": "6.26.0", "codecov": "3.6.5", "enzyme": "3.10.0", @@ -53,6 +52,7 @@ "dependencies": { "@cospired/i18n-iso-languages": "2.1.2", "axios": "0.18.1", + "axios-mock-adapter": "1.17.0", "form-urlencoded": "4.1.4", "glob": "7.1.6", "history": "4.10.1", diff --git a/src/auth/AxiosJwtAuthService.js b/src/auth/AxiosJwtAuthService.js index 0e8d42bbd..a06409dae 100644 --- a/src/auth/AxiosJwtAuthService.js +++ b/src/auth/AxiosJwtAuthService.js @@ -62,18 +62,19 @@ class AxiosJwtAuthService { } /** - * Gets the authenticated HTTP client singleton which is an axios instance. + * Gets the authenticated HTTP client for the service. This is an axios instance. * - * @returns {HttpClient} Singleton. A configured axios http client + * @returns {HttpClient} A configured axios http client which can be used for authenticated + * requests. */ getAuthenticatedHttpClient() { return this.authenticatedHttpClient; } /** - * Gets the unauthenticated HTTP lient singleton which is an axios instance. + * Gets the unauthenticated HTTP client for the service. This is an axios instance. * - * @returns {HttpClient} Singleton. A configured axios http client + * @returns {HttpClient} A configured axios http client. */ getHttpClient() { return this.httpClient; @@ -157,7 +158,6 @@ class AxiosJwtAuthService { * Sets the authenticated user to the provided value. * * @param {UserData} authUser - * @emits AUTHENTICATED_USER_CHANGED */ setAuthenticatedUser(authUser) { this.authenticatedUser = authUser; diff --git a/src/auth/MockAuthService.js b/src/auth/MockAuthService.js new file mode 100644 index 000000000..a02062c09 --- /dev/null +++ b/src/auth/MockAuthService.js @@ -0,0 +1,275 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import PropTypes from 'prop-types'; +import { ensureDefinedConfig } from '../utils'; + +const userPropTypes = PropTypes.shape({ + userId: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + roles: PropTypes.arrayOf(PropTypes.string), + administrator: PropTypes.boolean, +}); + +const optionsPropTypes = { + config: PropTypes.shape({ + BASE_URL: PropTypes.string.isRequired, + LMS_BASE_URL: PropTypes.string.isRequired, + LOGIN_URL: PropTypes.string.isRequired, + LOGOUT_URL: PropTypes.string.isRequired, + REFRESH_ACCESS_TOKEN_ENDPOINT: PropTypes.string.isRequired, + ACCESS_TOKEN_COOKIE_NAME: PropTypes.string.isRequired, + CSRF_TOKEN_API_PATH: PropTypes.string.isRequired, + }).isRequired, + loggingService: PropTypes.shape({ + logError: PropTypes.func.isRequired, + logInfo: PropTypes.func.isRequired, + }).isRequired, + // The absence of authenticatedUser means the user is anonymous. + authenticatedUser: userPropTypes, + // Must be at least a valid user, but may have other fields. + hydratedAuthenticatedUser: userPropTypes, +}; + +/** + * The MockAuthService class uses axios-mock-adapter to wrap it's HttpClients so that they can be + * mocked for development and testing. In an application, it could be used to temporarily override + * the "real" auth service: + * + * ``` + * import { + * initialize, + * APP_AUTH_INITIALIZED, + * subscribe, + * mergeConfig, + * } from '@edx/frontend-platform'; + * import { MockAuthService } from '@edx/frontend-platform/auth'; + * + * initialize({ + * handlers: { + * config: () => { + * mergeConfig({ + * authenticatedUser: { + * userId: 'abc123', + * username: 'Mock User', + * roles: [], + * administrator: false, + * }, + * hydratedAuthenticatedUser: { + * // Additional user props expected to be returned from a user accounts API, providing + * // additional user details beyond those present in the JWT. This data is added into + * // the user object if and when hydrateAuthenticatedUser is called. See documentation + * // for `hydrateAuthenticatedUser` for more details. + * } + * }); + * }, + * }, + * messages: [], + * authService: MockAuthService, + * }); + * + * subscribe(APP_AUTH_INITIALIZED, () => { + * // This variable is now a MockAdapter from axios-mock-adapter, allowing onGet, onPost, etc. + * // mocking. This handler will be called prior to any further initialization. See the + * // "Application Initialization" phases in the README for call order details. + * const mockAuthenticatedHttpClient = getAuthenticatedHttpClient(); + * // Mock calls here. + * }); + * ``` + * + * In a test where you would like to mock out API requests - perhaps from a redux-thunk function - + * you could do the following to set up a MockAuthService for your test: + * + * ``` + * import { getConfig } from '@edx/frontend-platform'; + * import { configure, MockAuthService } from '@edx/frontend-platform/auth'; + * + * const mockLoggingService = { + * logInfo: jest.fn(), + * logError: jest.fn(), + * }; + * configure(MockAuthService, { config: getConfig(), loggingService: mockLoggingService }); + * const mockAuthenticatedHttpClient = getAuthenticatedHttpClient(); + * + * // Mock calls for your tests. This configuration can be done in any sort of test setup. + * ``` + * + * NOTE: The login/logout methods related to redirecting currently maintain their real behaviors. A + * subsequent update to this mock service could allow them to be configured/mocked via the + * constructor's config options. + * + * @implements {AuthService} + * @memberof module:Auth + */ +class MockAuthService { + /** + * @param {Object} options + * @param {Object} options.config + * @param {string} options.config.BASE_URL + * @param {string} options.config.LMS_BASE_URL + * @param {string} options.config.LOGIN_URL + * @param {string} options.config.LOGOUT_URL + * @param {string} options.config.REFRESH_ACCESS_TOKEN_ENDPOINT + * @param {string} options.config.ACCESS_TOKEN_COOKIE_NAME + * @param {string} options.config.CSRF_TOKEN_API_PATH + * @param {Object} options.config.hydratedAuthenticatedUser + * @param {Object} options.config.authenticatedUser + * @param {Object} options.loggingService requires logError and logInfo methods + */ + constructor(options) { + this.authenticatedHttpClient = null; + this.httpClient = null; + + ensureDefinedConfig(options, 'AuthService'); + PropTypes.checkPropTypes(optionsPropTypes, options, 'options', 'AuthService'); + + this.config = options.config; + this.loggingService = options.loggingService; + + // Mock user + this.authenticatedUser = this.config.authenticatedUser ? this.config.authenticatedUser : null; + this.hydratedAuthenticatedUser = this.config.hydratedAuthenticatedUser ? + this.config.hydratedAuthenticatedUser : {}; + + this.authenticatedHttpClient = new MockAdapter(axios.create()); + this.httpClient = new MockAdapter(axios.create()); + } + + /** + * Gets the authenticated HTTP client instance, which is an axios client wrapped in + * MockAdapter from axios-mock-adapter. + * + * @returns {HttpClient} An HttpClient wrapped in MockAdapter. + */ + getAuthenticatedHttpClient() { + return this.authenticatedHttpClient; + } + + /** + * Gets the unauthenticated HTTP client instance, which is an axios client wrapped in + * MockAdapter from axios-mock-adapter. + * + * @returns {HttpClient} An HttpClient wrapped in MockAdapter. + */ + getHttpClient() { + return this.httpClient; + } + + /** + * Builds a URL to the login page with a post-login redirect URL attached as a query parameter. + * + * ``` + * const url = getLoginRedirectUrl('http://localhost/mypage'); + * console.log(url); // http://localhost/login?next=http%3A%2F%2Flocalhost%2Fmypage + * ``` + * + * @param {string} redirectUrl The URL the user should be redirected to after logging in. + */ + getLoginRedirectUrl(redirectUrl = this.config.BASE_URL) { + return `${this.config.LOGIN_URL}?next=${encodeURIComponent(redirectUrl)}`; + } + + /** + * Redirects the user to the login page. + * + * @param {string} redirectUrl The URL the user should be redirected to after logging in. + */ + redirectToLogin(redirectUrl = this.config.BASE_URL) { + global.location.assign(this.getLoginRedirectUrl(redirectUrl)); + } + + /** + * Builds a URL to the logout page with a post-logout redirect URL attached as a query parameter. + * + * ``` + * const url = getLogoutRedirectUrl('http://localhost/mypage'); + * console.log(url); // http://localhost/logout?next=http%3A%2F%2Flocalhost%2Fmypage + * ``` + * + * @param {string} redirectUrl The URL the user should be redirected to after logging out. + */ + getLogoutRedirectUrl(redirectUrl = this.config.BASE_URL) { + return `${this.config.LOGOUT_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`; + } + + /** + * Redirects the user to the logout page. + * + * @param {string} redirectUrl The URL the user should be redirected to after logging out. + */ + redirectToLogout(redirectUrl = this.config.BASE_URL) { + global.location.assign(this.getLogoutRedirectUrl(redirectUrl)); + } + + /** + * If it exists, returns the user data representing the currently authenticated user. If the + * user is anonymous, returns null. + * + * @returns {UserData|null} + */ + getAuthenticatedUser() { + return this.authenticatedUser; + } + + /** + * Sets the authenticated user to the provided value. + * + * @param {UserData} authUser + * @emits AUTHENTICATED_USER_CHANGED + */ + setAuthenticatedUser(authUser) { + this.authenticatedUser = authUser; + } + + /** + * Returns the current authenticated user details, as supplied in the `authenticatedUser` field + * of the config options. Resolves to null if the user is unauthenticated / the config option + * has not been set. + * + * @returns {Promise|Promise} Resolves to the user's access token if they are + * logged in. + */ + async fetchAuthenticatedUser() { + return this.getAuthenticatedUser(); + } + + /** + * Ensures a user is authenticated. It will redirect to login when not authenticated. + * + * @param {string} [redirectUrl=config.BASE_URL] to return user after login when not + * authenticated. + * @returns {Promise} + */ + async ensureAuthenticatedUser(redirectUrl = this.config.BASE_URL) { + await this.fetchAuthenticatedUser(); + + if (this.getAuthenticatedUser() === null) { + // The user is not authenticated, send them to the login page. + this.redirectToLogin(redirectUrl); + } + + return this.getAuthenticatedUser(); + } + + /** + * Adds the user data supplied in the `hydratedAuthenticatedUser` config option into the object + * returned by `getAuthenticatedUser`. This emulates the behavior of a real auth service which + * would make a request to fetch this data prior to merging it in. + * + * ``` + * console.log(authenticatedUser); // Will be sparse and only contain basic information. + * await hydrateAuthenticatedUser() + * const authenticatedUser = getAuthenticatedUser(); + * console.log(authenticatedUser); // Will contain additional user information + * ``` + * + * @returns {Promise} + */ + async hydrateAuthenticatedUser() { + const user = this.getAuthenticatedUser(); + if (user !== null) { + this.setAuthenticatedUser({ ...user, ...this.hydratedAuthenticatedUser }); + } + } +} + +export default MockAuthService; diff --git a/src/auth/index.js b/src/auth/index.js index b302c2cf1..40fbbf96c 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -15,3 +15,4 @@ export { hydrateAuthenticatedUser, } from './interface'; export { default as AxiosJwtAuthService } from './AxiosJwtAuthService'; +export { default as MockAuthService } from './MockAuthService'; diff --git a/src/auth/interface.js b/src/auth/interface.js index a7e3a87c0..e1a0f4a1b 100644 --- a/src/auth/interface.js +++ b/src/auth/interface.js @@ -29,6 +29,9 @@ * * As shown in this example, auth depends on the configuration document and logging. * + * NOTE: The documentation for AxiosJwtAuthService is nearly the same as that for the top-level + * auth interface, except that it contains some Axios-specific details. + * * @module Auth */ import PropTypes from 'prop-types'; @@ -117,56 +120,84 @@ export function resetAuthService() { } /** + * Gets the authenticated HTTP client for the service. * + * @returns {HttpClient} */ export function getAuthenticatedHttpClient() { return service.getAuthenticatedHttpClient(); } /** + * Gets the unauthenticated HTTP client for the service. * + * @returns {HttpClient} */ export function getHttpClient() { return service.getHttpClient(); } /** + * Builds a URL to the login page with a post-login redirect URL attached as a query parameter. * + * ``` + * const url = getLoginRedirectUrl('http://localhost/mypage'); + * console.log(url); // http://localhost/login?next=http%3A%2F%2Flocalhost%2Fmypage + * ``` + * + * @param {string} redirectUrl The URL the user should be redirected to after logging in. */ export function getLoginRedirectUrl(redirectUrl) { return service.getLoginRedirectUrl(redirectUrl); } /** + * Redirects the user to the login page. * + * @param {string} redirectUrl The URL the user should be redirected to after logging in. */ export function redirectToLogin(redirectUrl) { return service.redirectToLogin(redirectUrl); } /** + * Builds a URL to the logout page with a post-logout redirect URL attached as a query parameter. + * + * ``` + * const url = getLogoutRedirectUrl('http://localhost/mypage'); + * console.log(url); // http://localhost/logout?next=http%3A%2F%2Flocalhost%2Fmypage + * ``` * + * @param {string} redirectUrl The URL the user should be redirected to after logging out. */ export function getLogoutRedirectUrl(redirectUrl) { return service.getLogoutRedirectUrl(redirectUrl); } /** + * Redirects the user to the logout page. * + * @param {string} redirectUrl The URL the user should be redirected to after logging out. */ export function redirectToLogout(redirectUrl) { return service.redirectToLogout(redirectUrl); } /** + * If it exists, returns the user data representing the currently authenticated user. If the + * user is anonymous, returns null. * + * @returns {UserData|null} */ export function getAuthenticatedUser() { return service.getAuthenticatedUser(); } /** + * Sets the authenticated user to the provided value. * + * @param {UserData} authUser + * @emits AUTHENTICATED_USER_CHANGED */ export function setAuthenticatedUser(authUser) { service.setAuthenticatedUser(authUser); @@ -174,21 +205,40 @@ export function setAuthenticatedUser(authUser) { } /** + * Reads the authenticated user's access token. Resolves to null if the user is + * unauthenticated. * + * @returns {Promise|Promise} Resolves to the user's access token if they are + * logged in. */ export async function fetchAuthenticatedUser() { return service.fetchAuthenticatedUser(); } /** + * Ensures a user is authenticated. It will redirect to login when not + * authenticated. * + * @param {string} [redirectUrl=config.BASE_URL] to return user after login when not + * authenticated. + * @returns {Promise} */ export async function ensureAuthenticatedUser(redirectUrl) { return service.ensureAuthenticatedUser(redirectUrl); } /** + * Fetches additional user account information for the authenticated user and merges it into the + * existing authenticatedUser object, available via getAuthenticatedUser(). + * + * ``` + * console.log(authenticatedUser); // Will be sparse and only contain basic information. + * await hydrateAuthenticatedUser() + * const authenticatedUser = getAuthenticatedUser(); + * console.log(authenticatedUser); // Will contain additional user information + * ``` * + * @returns {Promise} */ export async function hydrateAuthenticatedUser() { return service.hydrateAuthenticatedUser(); diff --git a/src/config.js b/src/config.js index 6d2f5a32d..4928ea867 100644 --- a/src/config.js +++ b/src/config.js @@ -127,6 +127,7 @@ export function ensureConfig(keys, requester = 'unspecified application code') { subscribe(APP_CONFIG_INITIALIZED, () => { keys.forEach((key) => { if (config[key] === undefined) { + // eslint-disable-next-line no-console console.warn(`App configuration error: ${key} is required by ${requester}.`); } }); diff --git a/src/utils.js b/src/utils.js index b1820ed6f..4c6a10cb4 100644 --- a/src/utils.js +++ b/src/utils.js @@ -161,6 +161,7 @@ export function getQueryParameters(search = global.location.search) { export function ensureDefinedConfig(object, requester) { Object.keys(object).forEach((key) => { if (object[key] === undefined) { + // eslint-disable-next-line no-console console.warn(`Module configuration error: ${key} is required by ${requester}.`); } });