diff --git a/__mocks__/axios.js b/__mocks__/axios.js deleted file mode 100644 index aad497303f..0000000000 --- a/__mocks__/axios.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as endpoints from '../src/endpoints'; - -const axios = require('axios'); -const mockAPIConfigData = { - data: { - custom_virtualenvs: ['foo', 'bar'], - ansible_version: "2.7.2", - version: "2.1.1-40-g2758a3848" - } -}; -jest.genMockFromModule('axios'); - -axios.create = jest.fn(() => axios); -axios.get = jest.fn(() => axios); -axios.post = jest.fn(() => axios); -axios.create.mockReturnValue({ - get: axios.get, - post: axios.post -}); -axios.get.mockImplementation((endpoint) => { - if (endpoint === endpoints.API_CONFIG) { - return new Promise((resolve, reject) => { - resolve(mockAPIConfigData); - }); - } - else { - return 'get results'; - } -}); -axios.post.mockResolvedValue('post results'); - -axios.customClearMocks = () => { - axios.create.mockClear(); - axios.get.mockClear(); - axios.post.mockClear(); -}; - -module.exports = axios; diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index 5c9bc7d195..4d1efe68a4 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -4,7 +4,7 @@ import { HashRouter as Router } from 'react-router-dom'; import { shallow, mount } from 'enzyme'; import App from '../src/App'; import api from '../src/api'; -import { API_LOGOUT, API_CONFIG } from '../src/endpoints'; +import { API_LOGOUT } from '../src/endpoints'; import Dashboard from '../src/pages/Dashboard'; import { asyncFlush } from '../jest.setup'; @@ -45,6 +45,7 @@ describe('', () => { const appWrapper = shallow(); appWrapper.instance().onDevLogout(); appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); + expect(api.get).toHaveBeenCalledTimes(1); expect(api.get).toHaveBeenCalledWith(API_LOGOUT); await asyncFlush(); expect(appWrapper.state().activeItem).toBe(DEFAULT_ACTIVE_ITEM); diff --git a/__tests__/api.test.js b/__tests__/api.test.js index b05e8fdcbd..c4a3ba3542 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -1,80 +1,61 @@ -import mockAxios from 'axios'; import APIClient from '../src/api'; -import * as endpoints from '../src/endpoints'; -const CSRF_COOKIE_NAME = 'csrftoken'; -const CSRF_HEADER_NAME = 'X-CSRFToken'; - -const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; +const invalidCookie = 'invalid'; +const validLoggedOutCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=false; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; +const validLoggedInCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=true; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; describe('APIClient (api.js)', () => { - afterEach(() => { - mockAxios.customClearMocks(); + test('isAuthenticated returns false when cookie is invalid', () => { + APIClient.getCookie = jest.fn(() => invalidCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(false); }); - test('constructor calls axios create', () => { - const csrfObj = { - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME - }; - expect(mockAxios.create).toHaveBeenCalledTimes(1); - expect(mockAxios.create).toHaveBeenCalledWith(csrfObj); - expect(APIClient.http).toHaveProperty('get'); + test('isAuthenticated returns false when cookie is unauthenticated', () => { + APIClient.getCookie = jest.fn(() => validLoggedOutCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(false); }); - test('isAuthenticated checks authentication and sets cookie from document', () => { - APIClient.getCookie = jest.fn(); - const invalidCookie = 'invalid'; - const validLoggedOutCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=false; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; - const validLoggedInCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=true; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; - APIClient.getCookie.mockReturnValue(invalidCookie); - expect(APIClient.isAuthenticated()).toBe(false); - APIClient.getCookie.mockReturnValue(validLoggedOutCookie); - expect(APIClient.isAuthenticated()).toBe(false); - APIClient.getCookie.mockReturnValue(validLoggedInCookie); - expect(APIClient.isAuthenticated()).toBe(true); + test('isAuthenticated returns true when cookie is valid and authenticated', () => { + APIClient.getCookie = jest.fn(() => validLoggedInCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(true); }); - test('login calls get and post to login route, and sets cookie from document', (done) => { - const un = 'foo'; - const pw = 'bar'; - const next = 'baz'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${un}&password=${pw}&next=${next}`; - APIClient.setCookie = jest.fn(); - APIClient.login(un, pw, next).then(() => { - expect(mockAxios.get).toHaveBeenCalledTimes(1); - expect(mockAxios.get).toHaveBeenCalledWith(endpoints.API_LOGIN, { headers }); - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); + test('login calls get and post with expected content headers', async (done) => { + const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.login('username', 'password'); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers }); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual({ headers }); + + done(); }); - test('login encodes uri components for username, password and redirect', (done) => { - const un = '/foo/'; - const pw = '/bar/'; - const next = '/baz/'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${encodeURIComponent(un)}&password=${encodeURIComponent(pw)}&next=${encodeURIComponent(next)}`; - APIClient.login(un, pw, next).then(() => { - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); - }); + test('login sends expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); - test('login redirect defaults to config route when not explicitly passed', (done) => { - const un = 'foo'; - const pw = 'bar'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${un}&password=${pw}&next=${encodeURIComponent(endpoints.API_CONFIG)}`; - APIClient.setCookie = jest.fn(); - APIClient.login(un, pw).then(() => { - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); - }); + const api = new APIClient(mockHttp); + await api.login('foo', 'bar'); + await api.login('foo', 'bar', 'baz'); + expect(mockHttp.post).toHaveBeenCalledTimes(2); + expect(mockHttp.post.mock.calls[0]).toContainEqual('username=foo&password=bar&next=%2Fapi%2Fv2%2Fconfig%2F'); + expect(mockHttp.post.mock.calls[1]).toContainEqual('username=foo&password=bar&next=baz'); + + done(); + }); }); diff --git a/__tests__/index.test.jsx b/__tests__/index.test.jsx index f2b79ba7da..d6789513dc 100644 --- a/__tests__/index.test.jsx +++ b/__tests__/index.test.jsx @@ -1,22 +1,38 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; - -import api from '../src/api'; - +import { mount } from 'enzyme'; import { main } from '../src/index'; -const custom_logo = (
logo
); -const custom_login_info = 'custom login info'; - -jest.mock('react-dom', () => ({ render: jest.fn() })); +const render = template => mount(template); +const data = { custom_logo: 'foo', custom_login_info: '' } describe('index.jsx', () => { - test('renders without crashing', async () => { - api.getRoot = jest.fn().mockImplementation(() => Promise - .resolve({ data: { custom_logo, custom_login_info } })); + test('initialization', async (done) => { + const isAuthenticated = () => false; + const getRoot = jest.fn(() => Promise.resolve({ data })); - await main(); + const api = { getRoot, isAuthenticated }; + const wrapper = await main(render, api); - expect(ReactDOM.render).toHaveBeenCalled(); + expect(api.getRoot).toHaveBeenCalled(); + expect(wrapper.find('Dashboard')).toHaveLength(0); + expect(wrapper.find('Login')).toHaveLength(1); + + const { src } = wrapper.find('Login Brand img').props(); + expect(src).toContain(data.custom_logo); + + done(); + }); + + test('dashboard is loaded when authenticated', async (done) => { + const isAuthenticated = () => true; + const getRoot = jest.fn(() => Promise.resolve({ data })); + + const api = { getRoot, isAuthenticated }; + const wrapper = await main(render, api); + + expect(api.getRoot).toHaveBeenCalled(); + expect(wrapper.find('Dashboard')).toHaveLength(1); + expect(wrapper.find('Login')).toHaveLength(0); + + done(); }); }); diff --git a/__tests__/pages/Login.jsx b/__tests__/pages/Login.jsx index b0e3616776..09eb67b8dc 100644 --- a/__tests__/pages/Login.jsx +++ b/__tests__/pages/Login.jsx @@ -4,7 +4,7 @@ import { mount, shallow } from 'enzyme'; import { I18nProvider } from '@lingui/react'; import { asyncFlush } from '../../jest.setup'; import AtLogin from '../../src/pages/Login'; -import api from '../../src/api'; +import APIClient from '../../src/api'; describe('', () => { let loginWrapper; @@ -16,6 +16,8 @@ describe('', () => { let submitButton; let loginHeaderLogo; + const api = new APIClient({}); + const findChildren = () => { atLogin = loginWrapper.find('AtLogin'); loginPage = loginWrapper.find('LoginPage'); @@ -30,7 +32,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -59,7 +61,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -73,7 +75,7 @@ describe('', () => { loginWrapper = mount( - + ); @@ -166,7 +168,7 @@ describe('', () => { test('render Redirect to / when already authenticated', () => { api.isAuthenticated = jest.fn(); api.isAuthenticated.mockReturnValue(true); - loginWrapper = shallow(); + loginWrapper = shallow(); const redirectElem = loginWrapper.find('Redirect'); expect(redirectElem.length).toBe(1); expect(redirectElem.props().to).toBe('/'); diff --git a/src/api.js b/src/api.js index 007fa37f5c..902eee6a3c 100644 --- a/src/api.js +++ b/src/api.js @@ -1,5 +1,3 @@ -import axios from 'axios'; - const API_ROOT = '/api/'; const API_LOGIN = `${API_ROOT}login/`; const API_LOGOUT = `${API_ROOT}logout/`; @@ -7,21 +5,14 @@ const API_V2 = `${API_ROOT}v2/`; const API_CONFIG = `${API_V2}config/`; const API_ORGANIZATIONS = `${API_V2}organizations/`; -const CSRF_COOKIE_NAME = 'csrftoken'; -const CSRF_HEADER_NAME = 'X-CSRFToken'; const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; -const defaultHttpAdapter = axios.create({ - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME, -}); - class APIClient { static getCookie () { return document.cookie; } - constructor (httpAdapter = defaultHttpAdapter) { + constructor (httpAdapter) { this.http = httpAdapter; } diff --git a/src/index.jsx b/src/index.jsx index 5c63f5163f..b5f1ff6168 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,5 +1,6 @@ +import axios from 'axios'; import React from 'react'; -import { render } from 'react-dom'; +import ReactDOM from 'react-dom'; import { HashRouter, Redirect, @@ -48,15 +49,28 @@ import Users from './pages/Users'; import ja from '../build/locales/ja/messages'; import en from '../build/locales/en/messages'; -const catalogs = { en, ja }; +// +// Initialize http +// + +const http = axios.create({ xsrfCookieName: 'csrftoken', xsrfHeaderName: 'X-CSRFToken' }); + +// // Derive the language and region from global user agent data. Example: es-US -// https://developer.mozilla.org/en-US/docs/Web/API/Navigator +// see: https://developer.mozilla.org/en-US/docs/Web/API/Navigator +// + const language = (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage; const languageWithoutRegionCode = language.toLowerCase().split(/[_-]+/)[0]; +const catalogs = { en, ja }; -export async function main (api) { +// +// Function Main +// + +export async function main (render, api) { const el = document.getElementById('app'); // fetch additional config from server const { data } = await api.getRoot(); @@ -78,7 +92,7 @@ export async function main (api) { ); - render( + return render( {!api.isAuthenticated() ? loginRoutes : ( - } /> - } /> + ()} /> + ()} /> ( , el); }; -export default main(new APIClient()); +main(ReactDOM.render, new APIClient(http));