diff --git a/__mocks__/axios.js b/__mocks__/axios.js new file mode 100644 index 0000000000..23f96b475f --- /dev/null +++ b/__mocks__/axios.js @@ -0,0 +1,21 @@ +const axios = require('axios'); + +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.mockResolvedValue('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__/tests/api.test.js b/__tests__/tests/api.test.js new file mode 100644 index 0000000000..eb9136b203 --- /dev/null +++ b/__tests__/tests/api.test.js @@ -0,0 +1,117 @@ + +import mockAxios from 'axios'; +import APIClient from '../../src/api'; + +const API_ROOT = '/api/'; +const API_LOGIN = `${API_ROOT}login/`; +const API_LOGOUT = `${API_ROOT}logout/`; +const API_V2 = `${API_ROOT}v2/`; +const API_CONFIG = `${API_V2}config/`; +const API_PROJECTS = `${API_V2}projects/`; +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'; + +describe('APIClient (api.js)', () => { + afterEach(() => { + mockAxios.customClearMocks(); + }); + + 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 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('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(API_LOGIN, { headers }); + expect(mockAxios.post).toHaveBeenCalledTimes(1); + expect(mockAxios.post).toHaveBeenCalledWith(API_LOGIN, data, { 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(API_LOGIN, data, { headers }); + done(); + }); + }); + + 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(API_CONFIG)}`; + APIClient.setCookie = jest.fn(); + APIClient.login(un, pw).then(() => { + expect(mockAxios.post).toHaveBeenCalledTimes(1); + expect(mockAxios.post).toHaveBeenCalledWith(API_LOGIN, data, { headers }); + done(); + }); + }); + + test('logout calls get to logout route', () => { + APIClient.logout(); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + expect(mockAxios.get).toHaveBeenCalledWith(API_LOGOUT); + }); + + test('getConfig calls get to config route', () => { + APIClient.getConfig(); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + expect(mockAxios.get).toHaveBeenCalledWith(API_CONFIG); + }); + + test('getProjects calls get to projects route', () => { + APIClient.getProjects(); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + expect(mockAxios.get).toHaveBeenCalledWith(API_PROJECTS); + }); + + test('getOrganigzations calls get to organizations route', () => { + APIClient.getOrganizations(); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + expect(mockAxios.get).toHaveBeenCalledWith(API_ORGANIZATIONS); + }); + + test('getRoot calls get to root route', () => { + APIClient.getRoot(); + expect(mockAxios.get).toHaveBeenCalledTimes(1); + expect(mockAxios.get).toHaveBeenCalledWith(API_ROOT); + }); +}); diff --git a/src/api.js b/src/api.js index f442a8b765..45b330755c 100644 --- a/src/api.js +++ b/src/api.js @@ -11,6 +11,8 @@ 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'; + class APIClient { constructor () { this.http = axios.create({ @@ -19,10 +21,15 @@ class APIClient { }); } + /* eslint class-methods-use-this: ["error", { "exceptMethods": ["getCookie"] }] */ + getCookie () { + return document.cookie; + } + isAuthenticated () { let authenticated = false; - const parsed = (`; ${document.cookie}`).split('; userLoggedIn='); + const parsed = (`; ${this.getCookie()}`).split('; userLoggedIn='); if (parsed.length === 2) { authenticated = parsed.pop().split(';').shift() === 'true'; @@ -37,7 +44,7 @@ class APIClient { const next = encodeURIComponent(redirect); const data = `username=${un}&password=${pw}&next=${next}`; - const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; return this.http.get(API_LOGIN, { headers }) .then(() => this.http.post(API_LOGIN, data, { headers }));