Skip to content

Commit

Permalink
Merge pull request #6453 from NMDSdevopsServiceAdm/feat/1572-forgot-u…
Browse files Browse the repository at this point in the history
…sername-page-first-half

Feat/1572 forgot username page first half
  • Loading branch information
kapppa-joe authored Dec 12, 2024
2 parents cfc3af2 + 4fcfbd0 commit be346e1
Show file tree
Hide file tree
Showing 26 changed files with 806 additions and 12 deletions.
34 changes: 34 additions & 0 deletions backend/server/models/user.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* jshint indent: 2 */
const { Op } = require('sequelize');
const { padEnd } = require('lodash');
const { sanitise } = require('../utils/db');

module.exports = function (sequelize, DataTypes) {
Expand Down Expand Up @@ -486,5 +487,38 @@ module.exports = function (sequelize, DataTypes) {
});
};

User.findByRelevantInfo = async function ({ name, workplaceId, postcode, email }) {
if (!workplaceId && !postcode) {
return null;
}

const workplaceIdWithTrailingSpace = padEnd(workplaceId ?? '', 8, ' ');
const establishmentWhereClause = workplaceId
? { NmdsID: [workplaceId, workplaceIdWithTrailingSpace] }
: { postcode: postcode };

const query = {
attributes: ['uid', 'SecurityQuestionValue'],
where: {
Archived: false,
FullNameValue: name,
EmailValue: email,
SecurityQuestionValue: { [Op.ne]: null },
SecurityQuestionAnswerValue: { [Op.ne]: null },
},
include: [
{
model: sequelize.models.establishment,
where: establishmentWhereClause,
required: true,
attributes: [],
},
],
raw: true,
};

return this.findOne(query);
};

return User;
};
4 changes: 3 additions & 1 deletion backend/server/routes/registration.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
uuidv4();
const isLocal = require('../utils/security/isLocalTest').isLocal;
const { registerAccount } = require('./registration/registerAccount');
const models = require('../models');

const generateJWT = require('../utils/security/generateJWT');
const sendMail = require('../utils/email/notify-email').sendPasswordReset;
const { authLimiter } = require('../utils/middleware/rateLimiting');
const { findUserAccount } = require('./registration/findUserAccount');

router.use('/establishmentExistsCheck', require('./registration/establishmentExistsCheck'));

Expand Down Expand Up @@ -368,4 +368,6 @@ router.post('/validateResetPassword', async (req, res) => {
}
});

router.post('/findUserAccount', findUserAccount);

module.exports = router;
55 changes: 55 additions & 0 deletions backend/server/routes/registration/findUserAccount.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const { isEmpty } = require('lodash');
const { sanitisePostcode } = require('../../utils/postcodeSanitizer');
const models = require('../../models/index');

const findUserAccount = async (req, res) => {
try {
if (requestIsInvalid(req)) {
return res.status(400).send('Invalid request');
}

const { name, workplaceIdOrPostcode, email } = req.body;
let userFound = null;

const postcode = sanitisePostcode(workplaceIdOrPostcode);
if (postcode) {
userFound = await models.user.findByRelevantInfo({ name, postcode, email });
}

userFound =
userFound ?? (await models.user.findByRelevantInfo({ name, workplaceId: workplaceIdOrPostcode, email }));

if (userFound) {
return sendSuccessResponse(res, userFound);
}

return sendNotFoundResponse(res);
} catch (err) {
console.error('registration POST findUserAccount - failed', err);
return res.status(500).send('Internal server error');
}
};

const requestIsInvalid = (req) => {
if (!req.body) {
return true;
}
const { name, workplaceIdOrPostcode, email } = req.body;

return [name, workplaceIdOrPostcode, email].some((field) => isEmpty(field));
};

const sendSuccessResponse = (res, userFound) => {
const { uid, SecurityQuestionValue } = userFound;
return res.status(200).json({
accountFound: true,
accountUid: uid,
securityQuestion: SecurityQuestionValue,
});
};

const sendNotFoundResponse = (res) => {
return res.status(200).json({ accountFound: false, remainingAttempts: 4 });
};

module.exports = { findUserAccount };
151 changes: 151 additions & 0 deletions backend/server/test/unit/routes/registration/findUserAccount.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
const chai = require('chai');
const sinon = require('sinon');
const expect = chai.expect;
const httpMocks = require('node-mocks-http');

const { findUserAccount } = require('../../../../routes/registration/findUserAccount');
const models = require('../../../../models/index');

describe('backend/server/routes/registration/findUserAccount', () => {
const mockRequestBody = { name: 'Test User', workplaceIdOrPostcode: 'A1234567', email: '[email protected]' };

const buildRequest = (body) => {
const request = {
method: 'POST',
url: '/api/registration/findUserAccount',
body,
};
return httpMocks.createRequest(request);
};

let stubFindUser;
beforeEach(() => {
stubFindUser = sinon.stub(models.user, 'findByRelevantInfo').callsFake(({ workplaceId, postcode }) => {
if (workplaceId === 'A1234567' || postcode === 'LS1 2RP') {
return { uid: 'mock-uid', SecurityQuestionValue: 'What is your favourite colour?' };
}
return null;
});
});

afterEach(() => {
sinon.restore();
});

it('should respond with 200 and accountFound: true if user account is found', async () => {
const req = buildRequest(mockRequestBody);
const res = httpMocks.createResponse();

await findUserAccount(req, res);

expect(res.statusCode).to.equal(200);
expect(res._getJSONData()).to.deep.equal({
accountFound: true,
accountUid: 'mock-uid',
securityQuestion: 'What is your favourite colour?',
});

expect(stubFindUser).to.have.been.calledWith({
name: 'Test User',
workplaceId: 'A1234567',
email: '[email protected]',
});
});

it('should find user with postcode if request body contains a postcode', async () => {
const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'LS1 2RP' });
const res = httpMocks.createResponse();

await findUserAccount(req, res);

expect(res.statusCode).to.equal(200);
expect(res._getJSONData()).to.deep.equal({
accountFound: true,
accountUid: 'mock-uid',
securityQuestion: 'What is your favourite colour?',
});

expect(stubFindUser).to.have.been.calledWith({
name: 'Test User',
postcode: 'LS1 2RP',
email: '[email protected]',
});
});

it('should try to search with both postcode and workplace ID if incoming param is not distinguishable', async () => {
const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'AB101AB' });
const res = httpMocks.createResponse();

await findUserAccount(req, res);

expect(stubFindUser).to.have.been.calledWith({
name: 'Test User',
postcode: 'AB10 1AB',
email: '[email protected]',
});

expect(stubFindUser).to.have.been.calledWith({
name: 'Test User',
workplaceId: 'AB101AB',
email: '[email protected]',
});
});

it('should respond with 200 and accountFound: false if user account was not found', async () => {
const req = buildRequest({ ...mockRequestBody, workplaceIdOrPostcode: 'non-exist-workplace-id' });
const res = httpMocks.createResponse();

await findUserAccount(req, res);

expect(res.statusCode).to.equal(200);
expect(res._getJSONData()).to.deep.equal({
accountFound: false,
remainingAttempts: 4,
});
});

it('should respond with 400 error if request does not have a body', async () => {
const req = httpMocks.createRequest({
method: 'POST',
url: '/api/registration/findUserAccount',
});
const res = httpMocks.createResponse();

await findUserAccount(req, res);
expect(res.statusCode).to.equal(400);
});

it('should respond with 400 error if request body is empty', async () => {
const req = buildRequest({});
const res = httpMocks.createResponse();

await findUserAccount(req, res);
expect(res.statusCode).to.equal(400);
});

Object.keys(mockRequestBody).forEach((field) => {
it(`should respond with 400 error if ${field} is missing from request body`, async () => {
const body = { ...mockRequestBody };
delete body[field];

const req = buildRequest(body);
const res = httpMocks.createResponse();

await findUserAccount(req, res);
expect(res.statusCode).to.equal(400);
});
});

it('should respond with 500 Internal server error if an error occur when finding user', async () => {
const req = buildRequest(mockRequestBody);
const res = httpMocks.createResponse();

sinon.stub(console, 'error'); // suppress noisy logging
stubFindUser.rejects(new Error('mock database error'));

await findUserAccount(req, res);

expect(res.statusCode).to.equal(500);
expect(res._getData()).to.equal('Internal server error');
});
});
10 changes: 8 additions & 2 deletions frontend/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import { WorkplaceResolver } from '@core/resolvers/workplace.resolver';
import { AdminComponent } from '@features/admin/admin.component';
import { AscWdsCertificateComponent } from '@features/dashboard/asc-wds-certificate/asc-wds-certificate.component';
import { FirstLoginPageComponent } from '@features/first-login-page/first-login-page.component';
import { ForgotYourPasswordComponent } from '@features/forgot-your-password/forgot-your-password.component';
import { ForgotYourUsernameOrPasswordComponent } from '@features/forgot-your-password/forgot-your-username-or-password/forgot-your-username-or-password.component';
import { ForgotYourPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component';
import { ForgotYourUsernameOrPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-username-or-password.component';
import { ForgotYourUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component';
import { LoginComponent } from '@features/login/login.component';
import { LogoutComponent } from '@features/logout/logout.component';
import { MigratedUserTermsConditionsComponent } from '@features/migrated-user-terms-conditions/migrated-user-terms-conditions.component';
Expand Down Expand Up @@ -95,6 +96,11 @@ const routes: Routes = [
component: ForgotYourUsernameOrPasswordComponent,
data: { title: 'Forgot Your Username Or Password' },
},
{
path: 'forgot-your-username',
component: ForgotYourUsernameComponent,
data: { title: 'Forgot Your Username' },
},
{
path: 'reset-password',
component: ResetPasswordComponent,
Expand Down
25 changes: 16 additions & 9 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Angulartics2Module } from 'angulartics2';
import { HighchartsChartModule } from 'highcharts-angular';

import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from '@angular/common/http';
import { ErrorHandler, NgModule } from '@angular/core';
Expand Down Expand Up @@ -57,13 +60,19 @@ import { AscWdsCertificateComponent } from '@features/dashboard/asc-wds-certific
import { DashboardHeaderComponent } from '@features/dashboard/dashboard-header/dashboard-header.component';
import { DashboardComponent } from '@features/dashboard/dashboard.component';
import { HomeTabComponent } from '@features/dashboard/home-tab/home-tab.component';
import { StaffMismatchBannerComponent } from '@features/dashboard/home-tab/staff-mismatch-banner/staff-mismatch-banner.component';
import { FirstLoginPageComponent } from '@features/first-login-page/first-login-page.component';
import { FirstLoginWizardComponent } from '@features/first-login-wizard/first-login-wizard.component';
import { ForgotYourPasswordConfirmationComponent } from '@features/forgot-your-password/confirmation/confirmation.component';
import { ForgotYourPasswordEditComponent } from '@features/forgot-your-password/edit/edit.component';
import { ForgotYourPasswordComponent } from '@features/forgot-your-password/forgot-your-password.component';
import { ForgotYourPasswordConfirmationComponent } from '@features/forgot-your-username-or-password/forgot-your-password/confirmation/confirmation.component';
import { ForgotYourPasswordEditComponent } from '@features/forgot-your-username-or-password/forgot-your-password/edit/edit.component';
import { ForgotYourPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component';
import { ForgotYourUsernameOrPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-username-or-password.component';
import { FindAccountComponent } from '@features/forgot-your-username-or-password/forgot-your-username/find-account/find-account.component';
import { FindUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/find-username/find-username.component';
import { ForgotYourUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component';
import { LoginComponent } from '@features/login/login.component';
import { LogoutComponent } from '@features/logout/logout.component';
import { MigratedUserTermsConditionsComponent } from '@features/migrated-user-terms-conditions/migrated-user-terms-conditions.component';
import { BecomeAParentComponent } from '@features/new-dashboard/become-a-parent/become-a-parent.component';
import { DashboardWrapperComponent } from '@features/new-dashboard/dashboard-wrapper.component';
import { NewDashboardComponent } from '@features/new-dashboard/dashboard/dashboard.component';
Expand All @@ -79,20 +88,15 @@ import { NewWorkplaceTabComponent } from '@features/new-dashboard/workplace-tab/
import { ResetPasswordConfirmationComponent } from '@features/reset-password/confirmation/confirmation.component';
import { ResetPasswordEditComponent } from '@features/reset-password/edit/edit.component';
import { ResetPasswordComponent } from '@features/reset-password/reset-password.component';
import { SatisfactionSurveyComponent } from '@features/satisfaction-survey/satisfaction-survey.component';
import { BenchmarksModule } from '@shared/components/benchmarks-tab/benchmarks.module';
import { DataAreaTabModule } from '@shared/components/data-area-tab/data-area-tab.module';
import { FeatureFlagsService } from '@shared/services/feature-flags.service';
import { SharedModule } from '@shared/shared.module';
import { Angulartics2Module } from 'angulartics2';
import { HighchartsChartModule } from 'highcharts-angular';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { StaffMismatchBannerComponent } from './features/dashboard/home-tab/staff-mismatch-banner/staff-mismatch-banner.component';
import { MigratedUserTermsConditionsComponent } from './features/migrated-user-terms-conditions/migrated-user-terms-conditions.component';
import { SatisfactionSurveyComponent } from './features/satisfaction-survey/satisfaction-survey.component';
import { SentryErrorHandler } from './SentryErrorHandler.component';
import { ForgotYourUsernameOrPasswordComponent } from './features/forgot-your-password/forgot-your-username-or-password/forgot-your-username-or-password.component';

@NgModule({
declarations: [
Expand Down Expand Up @@ -135,6 +139,9 @@ import { ForgotYourUsernameOrPasswordComponent } from './features/forgot-your-pa
ParentWorkplaceAccounts,
DeleteWorkplaceComponent,
ForgotYourUsernameOrPasswordComponent,
ForgotYourUsernameComponent,
FindAccountComponent,
FindUsernameComponent,
],
imports: [
Angulartics2Module.forRoot({
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/app/core/services/find-username.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { environment } from 'src/environments/environment';

import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';

import { FindUsernameService } from './find-username.service';

describe('FindUsernameService', () => {
let service: FindUsernameService;
let http: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(FindUsernameService);
http = TestBed.inject(HttpTestingController);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

describe('findUserAccount', () => {
it('should make a POST request to /registration/findUserAccount endpoint with the given search params', async () => {
const mockParams = { name: 'Test user', workplaceIdOrPostcode: 'A1234567', email: '[email protected]' };

service.findUserAccount(mockParams).subscribe();
const req = http.expectOne(`${environment.appRunnerEndpoint}/api/registration/findUserAccount`);

expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(mockParams);
});
});
});
Loading

0 comments on commit be346e1

Please sign in to comment.