Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#25] [#36] [Backend] As a user, I can automatically re-authenticate if my access token expires [Backend] As a user, when I am unauthenticated I can see the Login page [#24] [Backend] As a user, when I am authenticated I can see my profile image at the top of all pages #34

Open
wants to merge 14 commits into
base: feature/21-backend-login-email-password
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cypress/fixtures/Authentication/logged-in-user.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"data": {
"id": "177",
"type": "user",
"attributes": {
"email": "[email protected]",
"name": "Team Nimble",
"avatar_url": "valid_avatar_url"
}
}
}
56 changes: 27 additions & 29 deletions cypress/integration/Authentication/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,38 @@ describe('User Authentication', () => {
});
});

// TODO: The below test fail on CI/CD with the use of Intercept

context('given valid credentials', () => {
it('redirects to the home page', () => {
cy.intercept('POST', '/oauth/token/', {
context('login with email and password', () => {
it('given correct credentials, redirects to the home page', () => {
cy.intercept('POST', 'api/v1/oauth/token', {
statusCode: 200,
fixture: 'Authentication/valid-credentials.json',
});

cy.intercept('GET', '/api/v1/me', { statusCode: 200, fixture: 'Authentication/logged-in-user.json' });

cy.visit('/login');

cy.get('input[name=email]').type('liam@nimblehq.co');
cy.get('input[name=password]').type('12345678');
cy.get('input[name=email]').type('example_email@nimblehq.co');
cy.get('input[name=password]').type('ValidPassword');
cy.get('button[type="submit"]').click();

cy.location().should((location) => {
expect(location.pathname).to.eq('/');
});
});
});

context('given NO credentials entered', () => {
it('shows field validation errors', () => {
cy.visit('/login');

cy.get('button[type="submit"]').click();

cy.get('.errors').should('be.visible');

cy.get('.errors').within(() => {
cy.contains('Email has invalid format');
cy.contains('Password should be at least');
});
cy.findByTestId('app-main-heading').should('be.visible');
});
});

// TODO: The below test fail on CI/CD with the use of Intercept

context('given INVALID credentials', () => {
it('shows login error', () => {
cy.intercept('POST', '/oauth/token/', {
it('given INCORRECT credentials, shows login error', () => {
cy.intercept('POST', 'api/v1/oauth/token', {
statusCode: 400,
fixture: 'Authentication/invalid-credentials.json',
});

cy.visit('/login');

cy.get('input[name=email]').type('[email protected]');
cy.get('input[name=password]').type('password123');
cy.get('input[name=email]').type('[email protected]');
cy.get('input[name=password]').type('InvalidPassword');
cy.get('button[type="submit"]').click();

cy.location().should((location) => {
Expand All @@ -67,6 +50,21 @@ describe('User Authentication', () => {
cy.get('.errors').within(() => {
cy.findByText('Your email or password is incorrect. Please try again.').should('exist');
});

cy.findByTestId('app-main-heading').should('not.exist');
});

it('given NO credentials entered, shows field validation errors', () => {
cy.visit('/login');

cy.get('button[type="submit"]').click();

cy.get('.errors').should('be.visible');

cy.get('.errors').within(() => {
cy.contains('Email has invalid format');
cy.contains('Password should be at least');
});
});
});
});
11 changes: 0 additions & 11 deletions cypress/integration/init.spec.ts

This file was deleted.

64 changes: 53 additions & 11 deletions src/adapters/authAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,83 @@
import nock from 'nock';

import AuthAdapter from './authAdapter';
import AuthAdapter, { OauthParams } from './authAdapter';

/* eslint-disable camelcase */
const testCredentials = {
const mockLoginCredentials = {
email: '[email protected]',
password: 'password123',
};

const commonLoginParams = {
grant_type: 'password',
client_id: process.env.REACT_APP_API_CLIENT_ID,
client_secret: process.env.REACT_APP_API_CLIENT_SECRET,
const commonUserProfileResponse = {
data: {
id: '1',
type: 'user',
attributes: {
email: '[email protected]',
name: 'TestName',
avatar_url: 'https://secure.gravatar.com/avatar/6733d09432e89459dba795de8312ac2d',
},
},
};
/* eslint-enable camelcase */

describe('AuthAdapter', () => {
afterAll(() => {
nock.cleanAll();
nock.restore();
});

describe('login', () => {
describe('loginWithEmailPassword', () => {
test('The login endpoint is called with credientials from the request', async () => {
const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`)
.defaultReplyHeaders({
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
})
.post('/oauth/token', {
...testCredentials,
...commonLoginParams,
...mockLoginCredentials,
...OauthParams,
grant_type: 'password',
})
.reply(200);

expect(scope.isDone()).toBe(false);
await AuthAdapter.login({ ...testCredentials });
await AuthAdapter.loginWithEmailPassword({ ...mockLoginCredentials });
expect(scope.isDone()).toBe(true);
});
});

describe('loginWithRefreshToken', () => {
test('The refresh token endpoint is called with refresh token from the request', async () => {
const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`)
.defaultReplyHeaders({
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
})
.post('/oauth/token', {
refresh_token: 'refresh_token',
...OauthParams,
grant_type: 'refresh_token',
})
.reply(200);

expect(scope.isDone()).toBe(false);
await AuthAdapter.loginWithRefreshToken('refresh_token');
expect(scope.isDone()).toBe(true);
});
});

describe('getUser', () => {
test('The user profile endpoint is called and returns user information', async () => {
const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`)
.defaultReplyHeaders({
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
})
.get('/me')
.reply(200, { ...commonUserProfileResponse });

expect(scope.isDone()).toBe(false);
expect(await AuthAdapter.getUser()).toEqual({ ...commonUserProfileResponse });
expect(scope.isDone()).toBe(true);
});
});
Expand Down
29 changes: 25 additions & 4 deletions src/adapters/authAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,40 @@ type LoginAuthType = {
password: string;
};

/* eslint-disable camelcase */
export const OauthParams = {
client_id: process.env.REACT_APP_API_CLIENT_ID,
client_secret: process.env.REACT_APP_API_CLIENT_SECRET,
};
/* eslint-enable camelcase */
class AuthAdapter extends BaseAdapter {
static login(params: LoginAuthType) {
static loginWithEmailPassword(authParams: LoginAuthType) {
/* eslint-disable camelcase */
const requestParams = {
...params,
...OauthParams,
...authParams,
grant_type: 'password',
client_id: process.env.REACT_APP_API_CLIENT_ID,
client_secret: process.env.REACT_APP_API_CLIENT_SECRET,
};
/* eslint-enable camelcase */

return this.prototype.postRequest('oauth/token', { data: requestParams });
}

static loginWithRefreshToken(refreshToken: string) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we rename this function? Because user won't login after running this function but get the new access token.

Suggested change
static loginWithRefreshToken(refreshToken: string) {
static refreshAccessToken(refreshToken: string) {
static refreshUserToken(refreshToken: string) {

Copy link
Owner Author

@liamstevens111 liamstevens111 Mar 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I named it this because the only difference between the login and refresh-token endpoint is we are sending a refresh_token instead of email/password, otherwise endpoint and response are identical.

I also deemed "login" just to be storing token in localstorage from endpoint response which is what both do.

Do you still think it's better to change it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, the user needs to log in with their email/password first before they get a refresh token. The user can't initially log in with a refresh token. So to get a new access token, it sounds like the user refreshes the token, not re-login.
Anyway I don't have a strong opinion about this one, I am fine with both name 👍

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't think of that - you're right there's no way to get a refresh_token without logging in first, so I will change the name 👍 @hanam1ni

Copy link
Owner Author

@liamstevens111 liamstevens111 Mar 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As authAdapter changed to all be inside surveyAdapter in the latest survey list PR i have included it here instead:
66a6f8c

@hanam1ni fyi

/* eslint-disable camelcase */
const requestParams = {
...OauthParams,
refresh_token: refreshToken,
grant_type: 'refresh_token',
};
/* eslint-enable camelcase */

return this.prototype.postRequest('oauth/token', { data: requestParams });
}

static getUser() {
return this.prototype.getRequest('me', {});
}
}

export default AuthAdapter;
44 changes: 44 additions & 0 deletions src/components/PrivateRoute/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable camelcase */
import { MemoryRouter } from 'react-router-dom';

import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';

import PrivateRoute from '.';
import { setItem, clearItem } from '../../helpers/localStorage';

const mockTokenData = {
access_token: 'test_access_token',
refresh_token: 'test_refresh_token',
token_type: 'Bearer',
expires_in: 7200,
created_at: 1677045997,
};

const mockUserProfileData = {
email: '[email protected]',
name: 'TestName',
avatar_url: 'https://secure.gravatar.com/avatar/6733d09432e89459dba795de8312ac2d',
};

describe('PrivateRoute', () => {
beforeEach(() => {
clearItem('UserProfile');
});

test('renders a PrivateRoute given authenticated user', async () => {
setItem('UserProfile', { auth: mockTokenData, user: mockUserProfileData });

render(<PrivateRoute />, { wrapper: MemoryRouter });

expect(localStorage.getItem).toBeCalledWith('UserProfile');
});

test.skip('renders a PrivateRoute', async () => {
// Infinite loop
render(<PrivateRoute />, { wrapper: MemoryRouter });

await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading'));

expect(screen.getByTestId('loading'));
});
});
38 changes: 38 additions & 0 deletions src/components/PrivateRoute/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { Navigate, Outlet, useOutletContext } from 'react-router-dom';

import { getItem } from 'helpers/localStorage';
import type { User } from 'types/User';

type ContextType = User;

import { LOGIN_URL } from '../../constants';

function PrivateRoute() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchCurrentUser = async () => {
const userProfile = getItem('UserProfile');
if (userProfile?.user) {
setUser({ ...userProfile.user });
}

setLoading(false);
};
fetchCurrentUser();
}, []);

if (loading) {
return <h3>Loading...</h3>;
}

return user ? <Outlet context={user} /> : <Navigate to={LOGIN_URL} />;
}

export default PrivateRoute;

export function useUser() {
return useOutletContext<ContextType>();
}
2 changes: 2 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const PASSWORD_MIN_LENGTH = 5;

export const LOGIN_URL = '/login';
43 changes: 43 additions & 0 deletions src/helpers/localStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { setItem, getItem, clearItem } from './localStorage';

/* eslint-disable camelcase */
describe('setItem', () => {
test('Given a valid UserToken, sets it in LocalStorage', () => {
const testToken = { access_token: 'access_token', refresh_token: 'refesh_token' };

setItem('UserProfile', testToken);

expect(JSON.parse(localStorage.getItem('UserProfile') as string)).toStrictEqual(testToken);
});
});

describe('getItem', () => {
test('Given an existing UserToken in LocalStorage, returns the value', () => {
const testToken = { access_token: 'access_token', refresh_token: 'refesh_token' };

localStorage.setItem('UserProfile', JSON.stringify(testToken));

expect(getItem('UserProfile')).toStrictEqual(testToken);

localStorage.clear();
});

test('Given a NON-existing UserToken in LocalStorage, returns null', () => {
expect(getItem('UserProfile')).toBeNull();
});
});

describe('clearItem', () => {
test('Given an existing UserToken in LocalStorage, removes the value', () => {
const testToken = { access_token: 'access_token', refresh_token: 'refesh_token' };

localStorage.setItem('UserProfile', JSON.stringify(testToken));

expect(JSON.parse(localStorage.getItem('UserProfile') as string)).toStrictEqual(testToken);

clearItem('UserProfile');

expect(JSON.parse(localStorage.getItem('UserProfile') as string)).toBeNull();
});
});
/* eslint-enable camelcase */
13 changes: 13 additions & 0 deletions src/helpers/localStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const getItem = (key: string) => {
const value = localStorage.getItem(key);

return value && JSON.parse(value);
};

export const setItem = (key: string, value: object) => {
localStorage.setItem(key, JSON.stringify(value));
};

export const clearItem = (key: string) => {
localStorage.removeItem(key);
};
Loading